Import Labyricorn Control Plane
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
PlayCircle,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Layers,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { BuildRecord } from "../types";
|
||||
|
||||
interface BuildEngineTabProps {
|
||||
builds: BuildRecord[];
|
||||
onTriggerBuild: () => Promise<BuildRecord>;
|
||||
onActivateLocal: (buildId: string) => void;
|
||||
}
|
||||
|
||||
export const BuildEngineTab: React.FC<BuildEngineTabProps> = ({
|
||||
builds,
|
||||
onTriggerBuild,
|
||||
onActivateLocal,
|
||||
}) => {
|
||||
const [selectedBuildId, setSelectedBuildId] = useState<string>(
|
||||
builds[0]?.id || "",
|
||||
);
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"logs" | "manifest" | "validation"
|
||||
>("logs");
|
||||
|
||||
const selectedBuild =
|
||||
builds.find((b) => b.id === selectedBuildId) || builds[0];
|
||||
|
||||
const handleBuild = async () => {
|
||||
setIsBuilding(true);
|
||||
try {
|
||||
const newBuild = await onTriggerBuild();
|
||||
setSelectedBuildId(newBuild.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">
|
||||
Publishing Pipeline
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-[#C5A059]/10 text-[#C5A059] border border-[#C5A059]/30">
|
||||
PRD AC-11, AC-12 & Manifest Compliance
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Resolves repositories, validates artifacts, executes projection
|
||||
builds, and constructs the final public output.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={isBuilding}
|
||||
className="px-5 py-2.5 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all flex items-center space-x-2 disabled:opacity-50 uppercase tracking-wider"
|
||||
>
|
||||
<PlayCircle
|
||||
className={`w-4 h-4 ${isBuilding ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>{isBuilding ? "Running Pipeline..." : "Run Pipeline"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MAIN TWO-COLUMN LAYOUT: BUILDS HISTORY LIST & DETAILED INSPECTOR */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT COLUMN: BUILD HISTORY (4 COLS) */}
|
||||
<div className="lg:col-span-4 space-y-3">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest block px-1">
|
||||
Pipeline Runs ({builds.length})
|
||||
</span>
|
||||
|
||||
{builds.map((b) => {
|
||||
const isSelected = b.id === selectedBuildId;
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
onClick={() => setSelectedBuildId(b.id)}
|
||||
className={`p-4 rounded-sm border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? "bg-[#181818] border-[#C5A059] shadow-xs"
|
||||
: "bg-[#111111] border-[#1F1F1F] hover:border-[#2A2A2A]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-bold font-mono text-white text-sm">
|
||||
{b.id}
|
||||
</span>
|
||||
{b.isActiveLocal && (
|
||||
<span className="px-2 py-0.5 bg-emerald-950/40 text-emerald-400 rounded-xs text-[10px] font-bold border border-emerald-500/20 uppercase">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{b.isStaged && (
|
||||
<span className="px-2 py-0.5 bg-[#1A1A1A] text-[#C5A059] rounded-xs text-[10px] font-bold border border-[#2A2A2A] uppercase">
|
||||
Staged
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-neutral-500">
|
||||
{new Date(b.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-neutral-400 font-mono">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-neutral-500">Routes:</span>
|
||||
<span className="text-neutral-200 font-semibold">
|
||||
{b.generatedRoutesCount} Pages
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-neutral-500">Duration:</span>
|
||||
<span className="text-neutral-200">{b.durationMs} ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-2 border-t border-[#1F1F1F] flex items-center justify-between text-[11px]">
|
||||
<span className="font-mono text-neutral-500 truncate max-w-[180px]">
|
||||
{b.artifactChecksum.substring(0, 16)}...
|
||||
</span>
|
||||
{!b.isActiveLocal && b.status !== "failed" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onActivateLocal(b.id);
|
||||
}}
|
||||
className="text-[#C5A059] hover:underline font-semibold flex items-center space-x-1"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
<span>Activate</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: INSPECTOR TABS (LOGS / MANIFEST / VALIDATION) (8 COLS) */}
|
||||
<div className="lg:col-span-8 bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col justify-between space-y-4">
|
||||
{selectedBuild ? (
|
||||
<>
|
||||
{/* HEADER & TAB BAR */}
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-[#1F1F1F] pb-4 mb-4 gap-2">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-bold font-mono text-white">
|
||||
{selectedBuild.id}
|
||||
</span>
|
||||
<span className="px-2.5 py-0.5 bg-[#1A1A1A] text-neutral-300 font-mono text-xs rounded-xs border border-[#262626]">
|
||||
Status: {selectedBuild.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-neutral-500 mt-0.5">
|
||||
Checksum:{" "}
|
||||
<strong className="text-[#C5A059]">
|
||||
{selectedBuild.artifactChecksum}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-[#0A0A0A] p-1 rounded-sm border border-[#1F1F1F] text-xs font-semibold text-neutral-400">
|
||||
<button
|
||||
onClick={() => setActiveTab("logs")}
|
||||
className={`px-3 py-1.5 rounded-xs transition-colors uppercase tracking-wider text-[11px] ${activeTab === "logs" ? "bg-[#1E1E1E] text-[#C5A059]" : ""}`}
|
||||
>
|
||||
Stages & Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("manifest")}
|
||||
className={`px-3 py-1.5 rounded-xs transition-colors uppercase tracking-wider text-[11px] ${activeTab === "manifest" ? "bg-[#1E1E1E] text-[#C5A059]" : ""}`}
|
||||
>
|
||||
Manifest
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("validation")}
|
||||
className={`px-3 py-1.5 rounded-xs transition-colors uppercase tracking-wider text-[11px] ${activeTab === "validation" ? "bg-[#1E1E1E] text-[#C5A059]" : ""}`}
|
||||
>
|
||||
Validation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TAB CONTENT: LOGS */}
|
||||
{activeTab === "logs" && (
|
||||
<div className="space-y-4 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{/* STAGES VISUALIZATION */}
|
||||
{selectedBuild.stages &&
|
||||
selectedBuild.stages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Pipeline Stages
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{selectedBuild.stages.map((stage, i) => {
|
||||
const isFailed = stage.status === "failed";
|
||||
const isSkipped = stage.status === "skipped";
|
||||
const isSucceeded = stage.status === "succeeded";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm text-xs"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-[#1A1A1A] border border-[#262626] font-mono text-[10px] text-neutral-500">
|
||||
{i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`font-medium ${isFailed ? "text-rose-400" : isSkipped ? "text-neutral-500" : "text-neutral-200"}`}
|
||||
>
|
||||
{stage.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 font-mono text-[11px]">
|
||||
{stage.durationMs !== undefined && (
|
||||
<span className="text-neutral-500">
|
||||
{stage.durationMs}ms
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`uppercase tracking-wider font-bold ${
|
||||
isFailed
|
||||
? "text-rose-400"
|
||||
: isSucceeded
|
||||
? "text-emerald-400"
|
||||
: isSkipped
|
||||
? "text-neutral-600"
|
||||
: "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{stage.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TERMINAL LOGS */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest mt-4">
|
||||
Raw Execution Logs
|
||||
</h3>
|
||||
<div className="bg-[#0A0A0A] text-neutral-300 font-mono text-xs rounded-sm p-4 space-y-2 border border-[#1F1F1F]">
|
||||
{selectedBuild.logs.map((log, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start space-x-2 py-0.5 border-b border-[#141414] last:border-0"
|
||||
>
|
||||
<span className="text-neutral-500 text-[10px] shrink-0">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={`font-semibold shrink-0 uppercase text-[10px] ${
|
||||
log.level === "error"
|
||||
? "text-rose-400"
|
||||
: log.level === "success"
|
||||
? "text-emerald-400"
|
||||
: "text-[#C5A059]"
|
||||
}`}
|
||||
>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="text-neutral-300 whitespace-pre-wrap leading-relaxed">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB CONTENT: MANIFEST */}
|
||||
{activeTab === "manifest" && (
|
||||
<div className="bg-[#0A0A0A] text-[#C5A059] font-mono text-xs rounded-sm p-4 border border-[#1F1F1F] overflow-x-auto">
|
||||
<pre>
|
||||
<code>
|
||||
{JSON.stringify(
|
||||
{
|
||||
siteDefinitionCommit: selectedBuild.siteDefCommit,
|
||||
sourceCommits: selectedBuild.sourceCommits,
|
||||
themeVersion: selectedBuild.themeVersion,
|
||||
builderVersion: selectedBuild.builderVersion,
|
||||
generatedRoutesCount:
|
||||
selectedBuild.generatedRoutesCount,
|
||||
mediaAssetsCount: selectedBuild.mediaCount,
|
||||
artifactChecksum: selectedBuild.artifactChecksum,
|
||||
artifactSizeBytes: selectedBuild.artifactSizeBytes,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB CONTENT: VALIDATION */}
|
||||
{activeTab === "validation" && (
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 rounded-sm text-emerald-300 space-y-1">
|
||||
<div className="flex items-center space-x-2 font-bold">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>
|
||||
Validation Passed: 0 Fatal Errors, 0 Route Collisions
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-emerald-400 text-[11px]">
|
||||
All required metadata, Markdown dialect constraints,
|
||||
YouTube embed IDs, and media namespace routes passed
|
||||
verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#1F1F1F] flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>
|
||||
Determinism Check:{" "}
|
||||
<strong className="text-emerald-400">
|
||||
Identical Inputs -> Identical Checksum
|
||||
</strong>
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
Artifact Size:{" "}
|
||||
{(selectedBuild.artifactSizeBytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500 text-xs">
|
||||
Select a build record to inspect details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ShieldCheck,
|
||||
Key,
|
||||
FileCode,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
Clock,
|
||||
GitCommit,
|
||||
Save,
|
||||
Lock,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { CredentialRef, AuditLogEntry, SiteConfig } from "../types";
|
||||
|
||||
interface ConfigAndAuditTabProps {
|
||||
credentials: CredentialRef[];
|
||||
auditLogs: AuditLogEntry[];
|
||||
siteConfig: SiteConfig;
|
||||
onAddCredential: (
|
||||
name: string,
|
||||
type: CredentialRef["type"],
|
||||
val: string,
|
||||
) => Promise<void>;
|
||||
onSaveSiteConfig: (rawYaml: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
|
||||
credentials,
|
||||
auditLogs,
|
||||
siteConfig,
|
||||
onAddCredential,
|
||||
onSaveSiteConfig,
|
||||
}) => {
|
||||
const [activeSubTab, setActiveSubTab] = useState<
|
||||
"credentials" | "audit" | "yaml"
|
||||
>("yaml");
|
||||
const [yamlContent, setYamlContent] = useState(siteConfig.rawYaml);
|
||||
const [yamlSavedMsg, setYamlSavedMsg] = useState<string | null>(null);
|
||||
|
||||
// New Credential Form
|
||||
const [showAddCredModal, setShowAddCredModal] = useState(false);
|
||||
const [credName, setCredName] = useState("");
|
||||
const [credType, setCredType] = useState<CredentialRef["type"]>("ssh_key");
|
||||
const [credVal, setCredVal] = useState("");
|
||||
|
||||
const handleSaveYaml = async () => {
|
||||
try {
|
||||
await onSaveSiteConfig(yamlContent);
|
||||
setYamlSavedMsg(
|
||||
"Updated site.yaml successfully! Created Git commit on site-definition repository.",
|
||||
);
|
||||
setTimeout(() => setYamlSavedMsg(null), 4000);
|
||||
} catch (err: any) {
|
||||
alert(`YAML Save Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCred = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!credName || !credVal) return;
|
||||
await onAddCredential(credName, credType, credVal);
|
||||
setShowAddCredModal(false);
|
||||
setCredName("");
|
||||
setCredVal("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">
|
||||
System Settings & Audit
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-emerald-950/40 text-emerald-400 border border-emerald-500/20">
|
||||
Control Plane Configuration
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Manage global site.yaml settings, secret credential references, and
|
||||
view system audit logs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SUB-TAB NAV */}
|
||||
<div className="flex border-b border-[#1F1F1F] space-x-6 text-xs font-semibold uppercase tracking-wider">
|
||||
<button
|
||||
onClick={() => setActiveSubTab("yaml")}
|
||||
className={`pb-3 transition-colors flex items-center space-x-2 border-b-2 ${
|
||||
activeSubTab === "yaml"
|
||||
? "border-[#C5A059] text-[#C5A059]"
|
||||
: "border-transparent text-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<FileCode className="w-4 h-4" />
|
||||
<span>Declarative site.yaml (Git Commits)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveSubTab("credentials")}
|
||||
className={`pb-3 transition-colors flex items-center space-x-2 border-b-2 ${
|
||||
activeSubTab === "credentials"
|
||||
? "border-[#C5A059] text-[#C5A059]"
|
||||
: "border-transparent text-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Secret Credential References ({credentials.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveSubTab("audit")}
|
||||
className={`pb-3 transition-colors flex items-center space-x-2 border-b-2 ${
|
||||
activeSubTab === "audit"
|
||||
? "border-[#C5A059] text-[#C5A059]"
|
||||
: "border-transparent text-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Audit Trail Log ({auditLogs.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSubTab === "yaml" ? (
|
||||
<div className="space-y-4">
|
||||
{yamlSavedMsg && (
|
||||
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 text-emerald-300 text-xs rounded-sm flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>{yamlSavedMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#111111] rounded-sm p-6 border border-[#1F1F1F] space-y-4">
|
||||
<div className="flex items-center justify-between text-white border-b border-[#1F1F1F] pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Code className="w-5 h-5 text-[#C5A059]" />
|
||||
<span className="font-mono font-bold text-sm">/site.yaml</span>
|
||||
<span className="text-xs text-neutral-400">
|
||||
Canonical Protocol Model
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveYaml}
|
||||
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black rounded-sm text-xs font-semibold shadow-sm transition-all flex items-center space-x-1.5 uppercase tracking-wider"
|
||||
>
|
||||
<GitCommit className="w-3.5 h-3.5" />
|
||||
<span>Save & Commit to Git</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={16}
|
||||
value={yamlContent}
|
||||
onChange={(e) => setYamlContent(e.target.value)}
|
||||
className="w-full bg-[#0A0A0A] text-[#C5A059] font-mono text-xs p-4 rounded-sm border border-[#1F1F1F] focus:outline-none focus:border-[#C5A059]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : activeSubTab === "credentials" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Secret Reference Vault
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowAddCredModal(true)}
|
||||
className="px-3.5 py-1.5 bg-[#C5A059] hover:bg-[#b38f4a] text-black rounded-sm text-xs font-semibold flex items-center space-x-1 uppercase tracking-wider"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Store Secret Reference</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{credentials.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="p-4 bg-[#111111] rounded-sm border border-[#1F1F1F] shadow-xs space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-white text-sm truncate">
|
||||
{c.name}
|
||||
</span>
|
||||
<Key className="w-4 h-4 text-[#C5A059] shrink-0" />
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 space-y-1">
|
||||
<div>
|
||||
Reference ID:{" "}
|
||||
<code className="text-[#C5A059] font-bold">{c.id}</code>
|
||||
</div>
|
||||
<div>
|
||||
Type:{" "}
|
||||
<span className="uppercase text-neutral-500">{c.type}</span>
|
||||
</div>
|
||||
<div className="p-2 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] text-neutral-300 truncate font-mono text-[10px]">
|
||||
{c.maskedValue}
|
||||
</div>
|
||||
<div className="pt-2 text-neutral-500 text-[10px] flex items-center justify-between">
|
||||
<span>Used {c.usageCount} times</span>
|
||||
<span>
|
||||
Created: {new Date(c.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* AUDIT LOG TRAIL */
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<h2 className="text-base font-bold text-white">
|
||||
System Audit Trail Log
|
||||
</h2>
|
||||
|
||||
<div className="bg-[#0A0A0A] text-neutral-200 font-mono text-xs rounded-sm p-4 border border-[#1F1F1F] space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{auditLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="py-2 border-b border-[#141414] last:border-0 flex items-start justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-[#C5A059] font-bold">
|
||||
[{log.category.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-white font-semibold">
|
||||
{log.action}
|
||||
</span>
|
||||
<span className="text-[10px] text-emerald-400 bg-emerald-950/40 border border-emerald-500/20 px-1.5 py-0.5 rounded-xs font-mono uppercase font-bold">
|
||||
{log.result}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-neutral-400 text-[11px] mt-1">
|
||||
{log.details}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-neutral-500 text-[10px]">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADD CREDENTIAL MODAL */}
|
||||
{showAddCredModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-xs z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] max-w-md w-full p-6 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#1F1F1F] pb-3">
|
||||
<h3 className="text-base font-bold text-white">
|
||||
Store New Secret Reference
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowAddCredModal(false)}
|
||||
className="text-neutral-500 hover:text-white text-xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateCred} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Secret Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Gitea Internal Deploy Key"
|
||||
value={credName}
|
||||
onChange={(e) => setCredName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm text-white placeholder-neutral-600 focus:outline-none focus:border-[#C5A059]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={credType}
|
||||
onChange={(e) => setCredType(e.target.value as any)}
|
||||
className="w-full px-3 py-2 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm text-white font-mono focus:outline-none focus:border-[#C5A059]"
|
||||
>
|
||||
<option value="ssh_key">SSH Private Key</option>
|
||||
<option value="token">OAuth / Deploy Token</option>
|
||||
<option value="password">Password</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Secret Value (Encrypted at rest, never written to Git)
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
required
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ..."
|
||||
value={credVal}
|
||||
onChange={(e) => setCredVal(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm text-white font-mono focus:outline-none focus:border-[#C5A059]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-3 border-t border-[#1F1F1F]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddCredModal(false)}
|
||||
className="px-4 py-2 border border-[#222222] text-neutral-300 rounded-sm hover:bg-[#1A1A1A]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#C5A059] text-black font-semibold rounded-sm hover:bg-[#b38f4a] uppercase tracking-wider"
|
||||
>
|
||||
Save Secret Reference
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,438 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Folder,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Youtube,
|
||||
ExternalLink,
|
||||
Code,
|
||||
Eye,
|
||||
Tag,
|
||||
AlertTriangle,
|
||||
ShieldCheck,
|
||||
Layers,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { ContentItem, MediaAsset } from "../types";
|
||||
|
||||
interface ContentTabProps {
|
||||
contentItems: ContentItem[];
|
||||
mediaAssets: MediaAsset[];
|
||||
onTriggerDiscovery: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ContentTab: React.FC<ContentTabProps> = ({
|
||||
contentItems,
|
||||
mediaAssets,
|
||||
onTriggerDiscovery,
|
||||
}) => {
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>(
|
||||
contentItems[0]?.id || "",
|
||||
);
|
||||
const [activeSubTab, setActiveSubTab] = useState<"markdown" | "media">(
|
||||
"markdown",
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState<ContentItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedItem =
|
||||
contentItems.find((i) => i.id === selectedItemId) || contentItems[0];
|
||||
|
||||
const filteredItems = contentItems.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.sourceRepo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.path.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.tags.some((t) => t.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
|
||||
const handleScan = async () => {
|
||||
setIsScanning(true);
|
||||
await onTriggerDiscovery();
|
||||
setIsScanning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER BAR */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">Artifacts</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-emerald-950/40 text-emerald-400 border border-emerald-500/20">
|
||||
PRD AC-05, AC-06, AC-07, AC-08 & AC-09
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Discovers artifacts across repositories, parses front matter,
|
||||
validates YouTube directives, Wikipedia links, raw HTML policy, and
|
||||
media mappings under{" "}
|
||||
<code className="text-[#C5A059] font-mono">/media</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={isScanning}
|
||||
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all flex items-center space-x-1.5 uppercase tracking-wider"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 ${isScanning ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>{isScanning ? "Scanning..." : "Run Discovery Scan"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SUB-TABS: MARKDOWN VS MEDIA */}
|
||||
<div className="flex border-b border-[#1F1F1F] space-x-6 text-xs font-semibold">
|
||||
<button
|
||||
onClick={() => setActiveSubTab("markdown")}
|
||||
className={`pb-3 transition-colors flex items-center space-x-2 border-b-2 uppercase tracking-wider ${
|
||||
activeSubTab === "markdown"
|
||||
? "border-[#C5A059] text-[#C5A059]"
|
||||
: "border-transparent text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Artifacts ({contentItems.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveSubTab("media")}
|
||||
className={`pb-3 transition-colors flex items-center space-x-2 border-b-2 uppercase tracking-wider ${
|
||||
activeSubTab === "media"
|
||||
? "border-[#C5A059] text-[#C5A059]"
|
||||
: "border-transparent text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
<span>Public Media Assets ({mediaAssets.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSubTab === "markdown" ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT LIST: DISCOVERED CONTENT ITEMS (5 COLS) */}
|
||||
<div className="lg:col-span-5 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-neutral-500 absolute left-3 top-2.5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search artifacts, tags, repositories..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-[#141414] border border-[#222222] rounded-sm text-xs text-white focus:outline-none focus:border-[#C5A059] shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredItems.map((item) => {
|
||||
const isSelected = item.id === selectedItemId;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
className={`p-4 rounded-sm border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? "bg-[#181818] border-[#C5A059] shadow-xs"
|
||||
: "bg-[#111111] border-[#1F1F1F] hover:border-[#2A2A2A]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-mono font-semibold text-[#C5A059]">
|
||||
{item.sourceRepo}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-500 font-mono">
|
||||
{item.published}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-bold text-white line-clamp-1 mb-1">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-400 line-clamp-2 mb-3">
|
||||
{item.summary}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] pt-2 border-t border-[#1F1F1F] text-neutral-500">
|
||||
<span className="font-mono text-neutral-300 truncate max-w-[200px]">
|
||||
{item.route}
|
||||
</span>
|
||||
<span className="flex items-center space-x-1 text-emerald-400 font-medium">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
<span>Valid</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: CONTENT INSPECTOR & DIRECTIVES (7 COLS) */}
|
||||
<div className="lg:col-span-7 bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-6">
|
||||
{selectedItem ? (
|
||||
<>
|
||||
{/* ITEM HEADER */}
|
||||
<div className="border-b border-[#1F1F1F] pb-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="px-2 py-0.5 text-[10px] font-semibold bg-[#1A1A1A] text-[#C5A059] border border-[#2A2A2A] rounded-xs font-mono">
|
||||
{selectedItem.sourceRepo}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 font-mono">
|
||||
{selectedItem.path}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
{selectedItem.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setPreviewContent(selectedItem)}
|
||||
className="px-3.5 py-1.5 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-colors flex items-center space-x-1.5 self-start uppercase tracking-wider"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>View Rendered</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EXPLAIN WHY / OMITTED STATUS */}
|
||||
{selectedItem.explainWhy && (
|
||||
<div className="p-4 bg-amber-950/20 border border-amber-900/40 rounded-sm text-xs">
|
||||
<h3 className="font-bold text-amber-400 mb-1">
|
||||
Artifact Omitted
|
||||
</h3>
|
||||
<p className="text-neutral-400 font-bold mb-1 mt-2">
|
||||
Reason:
|
||||
</p>
|
||||
<p className="text-neutral-300">
|
||||
{selectedItem.explainWhy}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* METADATA & FRONT MATTER INSPECTOR */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div className="p-4 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-2">
|
||||
<span className="font-bold text-neutral-300 uppercase tracking-widest block text-[10px]">
|
||||
Final Generated Route
|
||||
</span>
|
||||
<code className="font-mono text-[#C5A059] bg-[#141414] px-2.5 py-1 rounded-xs border border-[#222222] block text-xs truncate">
|
||||
{selectedItem.route}
|
||||
</code>
|
||||
<p className="text-neutral-500 text-[11px]">
|
||||
Resolved via{" "}
|
||||
<code className="text-neutral-300 font-mono">
|
||||
{selectedItem.styleInstanceId}
|
||||
</code>{" "}
|
||||
pattern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-2">
|
||||
<span className="font-bold text-neutral-300 uppercase tracking-widest block text-[10px]">
|
||||
Repository Entry Defaults
|
||||
</span>
|
||||
<div className="font-mono text-neutral-300 space-y-0.5 text-[11px]">
|
||||
<div>
|
||||
Project:{" "}
|
||||
<strong className="text-white">
|
||||
{selectedItem.metadata?.projectName}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
Project ID:{" "}
|
||||
<strong className="text-[#C5A059]">
|
||||
{selectedItem.metadata?.projectId}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SPECIAL DIRECTIVES DETECTED */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] font-bold text-neutral-400 uppercase tracking-widest block">
|
||||
Parsed Markdown Extensions & Embeds
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
{/* YouTube Directive */}
|
||||
<div className="p-3.5 bg-red-950/20 border border-red-900/40 rounded-sm space-y-1">
|
||||
<div className="flex items-center space-x-1.5 text-red-400 font-bold font-mono">
|
||||
<Youtube className="w-4 h-4" />
|
||||
<span>YouTube Directives (AC-06)</span>
|
||||
</div>
|
||||
{selectedItem.youtubeDirectives.length > 0 ? (
|
||||
selectedItem.youtubeDirectives.map((yt, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-[11px] text-red-200 font-mono"
|
||||
>
|
||||
Title: "{yt.title}" (ID: {yt.videoId})
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-neutral-500 text-[11px]">
|
||||
No YouTube directives in item.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wikipedia Links */}
|
||||
<div className="p-3.5 bg-blue-950/20 border border-blue-900/40 rounded-sm space-y-1">
|
||||
<div className="flex items-center space-x-1.5 text-blue-400 font-bold font-mono">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span>Wikipedia Links (AC-07)</span>
|
||||
</div>
|
||||
{selectedItem.wikipediaLinks.length > 0 ? (
|
||||
selectedItem.wikipediaLinks.map((wiki, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-[11px] text-blue-200 font-mono truncate"
|
||||
>
|
||||
{wiki.text} -> {wiki.url}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-neutral-500 text-[11px]">
|
||||
No Wikipedia links in item.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAW HTML POLICY SAFETY STATUS */}
|
||||
<div className="p-4 bg-[#0A0A0A] text-neutral-300 rounded-sm text-xs space-y-2 border border-[#1F1F1F] font-mono">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-[#C5A059] uppercase tracking-widest text-[10px]">
|
||||
Raw HTML Security Policy (AC-08)
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-emerald-950/40 text-emerald-400 rounded-xs font-bold text-[10px] border border-emerald-500/20 uppercase">
|
||||
Policy: Disabled / Sanitized
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-neutral-400 text-[11px]">
|
||||
Raw HTML blocks and arbitrary script tags are
|
||||
stripped/escaped automatically during build time to prevent
|
||||
XSS vulnerabilities.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500 text-xs">
|
||||
Select an artifact to inspect details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* PUBLIC MEDIA ASSETS INVENTORY (AC-09) */
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#1F1F1F] pb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-serif italic text-white">
|
||||
Public Media Assets Namespace Mappings (/media)
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">
|
||||
Maps source media directories into isolated public media
|
||||
namespaces to prevent path collisions.
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-amber-950/40 text-amber-400 rounded-xs text-xs font-semibold border border-amber-500/20 uppercase tracking-widest">
|
||||
AC-09 Verified
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
{mediaAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="p-4 rounded-sm border border-[#1F1F1F] bg-[#0A0A0A] space-y-2 font-mono"
|
||||
>
|
||||
<div className="flex items-center justify-between text-[#C5A059] font-bold">
|
||||
<span>{asset.filename}</span>
|
||||
<span className="text-[10px] text-neutral-500">
|
||||
{(asset.sizeBytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 space-y-1">
|
||||
<div>
|
||||
Source Repo:{" "}
|
||||
<strong className="text-white">{asset.sourceRepo}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Original Path:{" "}
|
||||
<code className="text-neutral-300">
|
||||
{asset.originalPath}
|
||||
</code>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-[#1F1F1F]">
|
||||
<span className="text-neutral-500 block text-[10px] uppercase">
|
||||
Public Namespace Route:
|
||||
</span>
|
||||
<code className="text-[#C5A059] font-semibold text-[11px] block truncate">
|
||||
{asset.publicNamespacePath}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RENDERED CONTENT PREVIEW MODAL */}
|
||||
{previewContent && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-xs z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] max-w-2xl w-full p-6 shadow-2xl space-y-4 max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between border-b border-[#1F1F1F] pb-3">
|
||||
<div>
|
||||
<span className="text-xs font-mono text-[#C5A059] font-bold">
|
||||
{previewContent.sourceRepo} • {previewContent.published}
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{previewContent.title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreviewContent(null)}
|
||||
className="text-neutral-500 hover:text-white text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-invert max-w-none text-xs text-neutral-200 leading-relaxed font-sans space-y-3">
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] font-mono text-[11px] text-neutral-400">
|
||||
Summary: {previewContent.summary}
|
||||
</div>
|
||||
<pre className="bg-[#0A0A0A] text-neutral-200 p-4 rounded-sm text-[11px] font-mono border border-[#1F1F1F] overflow-x-auto">
|
||||
<code>{previewContent.contentMarkdown}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end pt-3 border-t border-[#1F1F1F]">
|
||||
<button
|
||||
onClick={() => setPreviewContent(null)}
|
||||
className="px-4 py-2 bg-[#1A1A1A] hover:bg-[#262626] text-white rounded-sm text-xs font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Close Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import React from "react";
|
||||
import {
|
||||
GitBranch,
|
||||
Server,
|
||||
FileText,
|
||||
UploadCloud,
|
||||
PlayCircle,
|
||||
ArrowUpRight,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BuildRecord,
|
||||
GitSource,
|
||||
NginxStatus,
|
||||
PushTarget,
|
||||
ContentItem,
|
||||
} from "../types";
|
||||
|
||||
interface DashboardTabProps {
|
||||
sources: GitSource[];
|
||||
contentItems: ContentItem[];
|
||||
builds: BuildRecord[];
|
||||
nginxStatus: NginxStatus | null;
|
||||
pushTargets: PushTarget[];
|
||||
onTriggerBuild: () => void;
|
||||
onNavigateTab: (tab: string) => void;
|
||||
onActivateLocal: (buildId: string) => void;
|
||||
}
|
||||
|
||||
export const DashboardTab: React.FC<DashboardTabProps> = ({
|
||||
sources,
|
||||
contentItems,
|
||||
builds,
|
||||
nginxStatus,
|
||||
pushTargets,
|
||||
onTriggerBuild,
|
||||
onNavigateTab,
|
||||
onActivateLocal,
|
||||
}) => {
|
||||
const activeBuild = builds.find((b) => b.isActiveLocal) || builds[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HERO SYSTEM HEADER */}
|
||||
<div className="bg-[#111111] text-white rounded-sm p-8 border border-[#1F1F1F] shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1/3 opacity-15 pointer-events-none">
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none">
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="80"
|
||||
stroke="#C5A059"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="60"
|
||||
stroke="#C5A059"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-emerald-950/40 text-emerald-400 border border-emerald-500/20 flex items-center space-x-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
<span>Operational Control Plane</span>
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-4xl font-serif italic text-white tracking-tight">
|
||||
Welcome to Labyricorn.
|
||||
</h1>
|
||||
<p className="text-neutral-400 text-xs sm:text-sm mt-2 max-w-2xl leading-relaxed">
|
||||
The administrative control plane for your Git-driven publishing
|
||||
platform. Labyricorn orchestrates the generation of your static
|
||||
website directly from your Git repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => onNavigateTab("preview")}
|
||||
className="px-5 py-2.5 bg-[#181818] hover:bg-[#222222] text-neutral-200 border border-[#2A2A2A] rounded-sm text-xs font-semibold transition-all flex items-center space-x-2"
|
||||
>
|
||||
<ArrowUpRight className="w-4 h-4 text-[#C5A059]" />
|
||||
<span className="uppercase tracking-wider">
|
||||
Preview Live Site
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<div className="bg-[#111111] border border-[#1F1F1F] rounded-sm p-12 flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-[#181818] border border-[#262626] flex items-center justify-center mb-4">
|
||||
<GitBranch className="w-8 h-8 text-[#C5A059]" />
|
||||
</div>
|
||||
<h2 className="text-xl font-serif italic text-white mb-2">
|
||||
No repositories have been discovered.
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-400 max-w-md mb-6 leading-relaxed">
|
||||
Connect git.labyricorn.com to begin discovering repositories and
|
||||
building your local site.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onNavigateTab("repositories")}
|
||||
className="px-5 py-2.5 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all shadow-md flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="uppercase tracking-wider font-bold">
|
||||
Connect Repository
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : builds.length === 0 ? (
|
||||
<div className="bg-[#111111] border border-[#1F1F1F] rounded-sm p-12 flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-[#181818] border border-[#262626] flex items-center justify-center mb-4">
|
||||
<PlayCircle className="w-8 h-8 text-[#C5A059]" />
|
||||
</div>
|
||||
<h2 className="text-xl font-serif italic text-white mb-2">
|
||||
No website projection has been built.
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-400 max-w-md mb-6 leading-relaxed">
|
||||
Validate at least one repository, then run the first projection.
|
||||
</p>
|
||||
<button
|
||||
onClick={onTriggerBuild}
|
||||
className="px-5 py-2.5 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all shadow-md flex items-center space-x-2"
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
<span className="uppercase tracking-wider font-bold">
|
||||
Publish First Build
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[#111111] rounded-sm p-5 border border-[#1F1F1F] shadow-xs flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Active Local Release
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-sm bg-[#1A1A1A] text-[#C5A059] flex items-center justify-center border border-[#262626]">
|
||||
<Server className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-2xl font-bold font-mono text-white">
|
||||
{activeBuild ? activeBuild.id : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111111] rounded-sm p-5 border border-[#1F1F1F] shadow-xs flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Repositories
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-sm bg-[#1A1A1A] text-[#C5A059] flex items-center justify-center border border-[#262626]">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{sources.length} Repos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111111] rounded-sm p-5 border border-[#1F1F1F] shadow-xs flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Discovered Artifacts
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-sm bg-[#1A1A1A] text-[#C5A059] flex items-center justify-center border border-[#262626]">
|
||||
<FileText className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{contentItems.length} Artifacts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111111] rounded-sm p-5 border border-[#1F1F1F] shadow-xs flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest">
|
||||
Publishing Targets
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-sm bg-[#1A1A1A] text-[#C5A059] flex items-center justify-center border border-[#262626]">
|
||||
<UploadCloud className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-2xl font-bold font-mono text-white">
|
||||
{pushTargets.length > 0
|
||||
? pushTargets[0].lastDeployedBuildId || "Pending"
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PLATFORM PHILOSOPHY */}
|
||||
<div className="bg-[#111111] rounded-sm p-6 border border-[#1F1F1F] shadow-xs space-y-4">
|
||||
<h2 className="text-lg font-serif italic text-white">
|
||||
Platform Architecture
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 text-xs text-neutral-400">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-bold text-white uppercase tracking-widest text-[10px]">
|
||||
Gitea
|
||||
</h3>
|
||||
<p>Gitea stores the authoritative repositories.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-bold text-white uppercase tracking-widest text-[10px]">
|
||||
Artifacts
|
||||
</h3>
|
||||
<p>Labyricorn discovers and validates repository artifacts.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-bold text-white uppercase tracking-widest text-[10px]">
|
||||
Projections
|
||||
</h3>
|
||||
<p>
|
||||
Projections transform those artifacts into public experiences.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-bold text-white uppercase tracking-widest text-[10px]">
|
||||
Publishing
|
||||
</h3>
|
||||
<p>
|
||||
Publishing places a completed projection onto the local canonical
|
||||
site and optional remote targets.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-bold text-white uppercase tracking-widest text-[10px]">
|
||||
Control Plane
|
||||
</h3>
|
||||
<p>
|
||||
This interface orchestrates the pipeline without replacing Git as
|
||||
the source of truth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,708 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
GitBranch,
|
||||
Folder,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Key,
|
||||
HardDrive,
|
||||
ShieldCheck,
|
||||
Search,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { GitSource, CredentialRef } from "../types";
|
||||
|
||||
interface GitSourcesTabProps {
|
||||
sources: GitSource[];
|
||||
credentials: CredentialRef[];
|
||||
onAddSource: (src: Partial<GitSource>) => void;
|
||||
onTestSource: (id: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export const GitSourcesTab: React.FC<GitSourcesTabProps> = ({
|
||||
sources,
|
||||
credentials,
|
||||
onAddSource,
|
||||
onTestSource,
|
||||
}) => {
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string>(
|
||||
sources[0]?.id || "titleflow",
|
||||
);
|
||||
const [treeFiles, setTreeFiles] = useState<
|
||||
Array<{ path: string; type: string; size: string }>
|
||||
>([
|
||||
{ path: ".devlogs/pack-validation.md", type: "file", size: "2.8 KB" },
|
||||
{ path: ".devlogs/schema-refactor.md", type: "file", size: "1.9 KB" },
|
||||
{ path: "media/screenshots/header.webp", type: "file", size: "142 KB" },
|
||||
{ path: "src/engine.ts", type: "file", size: "14.2 KB" },
|
||||
{ path: "package.json", type: "file", size: "1.1 KB" },
|
||||
]);
|
||||
const [isTesting, setIsTesting] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
id: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
} | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [activeInspectorTab, setActiveInspectorTab] = useState<
|
||||
"overview" | "health" | "artifacts" | "publishing"
|
||||
>("overview");
|
||||
|
||||
// New Source Form
|
||||
const [newRepoId, setNewRepoId] = useState("");
|
||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||
const [newRef, setNewRef] = useState("main");
|
||||
const [newType, setNewType] = useState("hybrid");
|
||||
const [newCredential, setNewCredential] = useState("secret:gitea-deploy-key");
|
||||
const [newRequired, setNewRequired] = useState(true);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
const handleSourceSelect = async (id: string) => {
|
||||
setSelectedSourceId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/git-sources/${id}/tree`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTreeFiles(data.files || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async () => {
|
||||
setIsDiscovering(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/gitea/discover", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to discover");
|
||||
setTestResult({ id: "Discovery", message: data.message, success: true });
|
||||
} catch (err: any) {
|
||||
setTestResult({ id: "Discovery", message: err.message, success: false });
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setIsTesting(id);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const data = await onTestSource(id);
|
||||
setTestResult({ id, message: data.message, success: data.success });
|
||||
} catch (err: any) {
|
||||
setTestResult({
|
||||
id,
|
||||
message: err.message || "Connection test failed",
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSource = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newRepoId || !newRepoUrl) return;
|
||||
|
||||
onAddSource({
|
||||
id: newRepoId,
|
||||
type: newType as any,
|
||||
repository: newRepoUrl,
|
||||
ref: newRef,
|
||||
required: newRequired,
|
||||
credential: newCredential,
|
||||
branch: newRef,
|
||||
});
|
||||
|
||||
setShowAddModal(false);
|
||||
setNewRepoId("");
|
||||
setNewRepoUrl("");
|
||||
setNewType("hybrid");
|
||||
};
|
||||
|
||||
const selectedSource = sources.find((s) => s.id === selectedSourceId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER SECTION */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">
|
||||
Repositories
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-[#C5A059]/10 text-[#C5A059] border border-[#C5A059]/30">
|
||||
Source Inventory
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Connect standard SSH, HTTPS, or local Git repositories. Repositories
|
||||
without .labyricorn/site.yml are ignored during build.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 self-start md:self-auto">
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={isDiscovering}
|
||||
className="px-4 py-2 bg-[#1A1A1A] hover:bg-[#2A2A2A] text-white font-semibold rounded-sm text-xs transition-all flex items-center space-x-1.5 uppercase tracking-wider border border-[#2A2A2A]"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isDiscovering ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>{isDiscovering ? "Discovering..." : "Discover Gitea"}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all flex items-center space-x-1.5 uppercase tracking-wider"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Repository</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TEST RESULT NOTIFICATION */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`p-4 rounded-sm border text-xs flex items-start justify-between ${
|
||||
testResult.success
|
||||
? "bg-emerald-950/40 border-emerald-500/30 text-emerald-300"
|
||||
: "bg-amber-950/40 border-amber-500/30 text-amber-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
{testResult.success ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-bold block">
|
||||
Connectivity Test Result ({testResult.id})
|
||||
</span>
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTestResult(null)}
|
||||
className="text-neutral-400 hover:text-white text-base leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MAIN TWO-COLUMN LAYOUT: SOURCES LIST & TREE BROWSER */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT COLUMN: SOURCES LIST (5 COLS) */}
|
||||
<div className="lg:col-span-5 space-y-3">
|
||||
<div className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest px-1">
|
||||
Configured Repositories ({sources.length})
|
||||
</div>
|
||||
|
||||
{sources.map((source) => {
|
||||
const isSelected = source.id === selectedSourceId;
|
||||
const health = source.health || "Healthy";
|
||||
const healthColor =
|
||||
health === "Healthy"
|
||||
? "text-emerald-400"
|
||||
: health === "Warning"
|
||||
? "text-amber-400"
|
||||
: health === "Invalid"
|
||||
? "text-rose-400"
|
||||
: "text-neutral-400";
|
||||
const healthBg =
|
||||
health === "Healthy"
|
||||
? "bg-emerald-400"
|
||||
: health === "Warning"
|
||||
? "bg-amber-400"
|
||||
: health === "Invalid"
|
||||
? "bg-rose-400"
|
||||
: "bg-neutral-400";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
onClick={() => handleSourceSelect(source.id)}
|
||||
className={`p-4 rounded-sm border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[#181818] border-[#C5A059] shadow-xs"
|
||||
: "bg-[#111111] border-[#1F1F1F] hover:border-[#2A2A2A]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-bold text-white text-sm font-mono">
|
||||
{source.id}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-neutral-800 text-neutral-400 rounded-xs capitalize">
|
||||
{source.type || "Hybrid"}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center space-x-1 text-xs font-medium ${healthColor}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${healthBg}`}
|
||||
></span>
|
||||
<span>{health}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs font-mono text-neutral-400">
|
||||
<p className="truncate text-neutral-200 bg-[#0A0A0A] px-2 py-1 rounded-xs text-[11px] border border-[#1F1F1F]">
|
||||
{source.repository}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-1 text-[11px] text-neutral-500">
|
||||
<span>
|
||||
Ref:{" "}
|
||||
<strong className="text-neutral-300">{source.ref}</strong>
|
||||
</span>
|
||||
<span>
|
||||
SHA:{" "}
|
||||
<strong className="text-[#C5A059]">
|
||||
{source.lastResolvedCommit?.substring(0, 7) || "HEAD"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-[#1F1F1F] flex items-center justify-between text-xs">
|
||||
{source.credential ? (
|
||||
<span className="text-neutral-500 flex items-center space-x-1 text-[11px]">
|
||||
<Key className="w-3 h-3 text-[#C5A059]" />
|
||||
<span className="font-mono text-neutral-400">
|
||||
{source.credential}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-500 text-[11px]">
|
||||
{source.visibility || "Public in Gitea"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTest(source.id);
|
||||
}}
|
||||
disabled={isTesting === source.id}
|
||||
className="px-2.5 py-1 bg-[#1A1A1A] hover:bg-[#262626] border border-[#2A2A2A] rounded-xs text-neutral-300 text-[11px] font-medium transition-colors flex items-center space-x-1"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3 h-3 ${isTesting === source.id ? "animate-spin text-[#C5A059]" : ""}`}
|
||||
/>
|
||||
<span>Test</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: REPOSITORY INSPECTOR (7 COLS) */}
|
||||
<div className="lg:col-span-7 bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col h-[700px]">
|
||||
{selectedSource ? (
|
||||
<>
|
||||
{/* INSPECTOR HEADER */}
|
||||
<div className="border-b border-[#1F1F1F] pb-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Folder className="w-5 h-5 text-[#C5A059]" />
|
||||
<h2 className="text-base font-bold text-white font-mono">
|
||||
{selectedSource.id}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-xs bg-[#1A1A1A] px-2.5 py-1 rounded-xs text-neutral-300 font-mono border border-[#262626]">
|
||||
Ref: {selectedSource.ref}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1 font-mono">
|
||||
{selectedSource.repository}
|
||||
</p>
|
||||
|
||||
{/* STATUS SUMMARY BAR */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-4 text-[11px]">
|
||||
<span className="text-neutral-400">
|
||||
Reachability:{" "}
|
||||
<strong className="text-neutral-200">
|
||||
{selectedSource.reachability || "Healthy"}
|
||||
</strong>
|
||||
</span>
|
||||
<span className="text-neutral-600">|</span>
|
||||
<span className="text-neutral-400">
|
||||
Config:{" "}
|
||||
<strong className="text-neutral-200">
|
||||
{selectedSource.configurationState || "Valid"}
|
||||
</strong>
|
||||
</span>
|
||||
<span className="text-neutral-600">|</span>
|
||||
<span className="text-neutral-400">
|
||||
Projection:{" "}
|
||||
<strong className="text-neutral-200">
|
||||
{selectedSource.projectionEnabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* INSPECTOR TABS */}
|
||||
<div className="flex items-center space-x-1 border-b border-[#1F1F1F] mb-4">
|
||||
{[
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "health", label: "Health & Validation" },
|
||||
{ id: "artifacts", label: "Artifacts" },
|
||||
{ id: "publishing", label: "Publishing" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveInspectorTab(tab.id as any)}
|
||||
className={`px-4 py-2 text-xs font-semibold uppercase tracking-wider border-b-2 transition-colors ${
|
||||
activeInspectorTab === tab.id
|
||||
? "border-[#C5A059] text-white bg-[#1A1A1A]"
|
||||
: "border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-[#161616]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* INSPECTOR CONTENT */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar space-y-4">
|
||||
{activeInspectorTab === "overview" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest mb-2">
|
||||
Repository Classification
|
||||
</h3>
|
||||
<div className="bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm p-4 text-xs text-neutral-300">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="block text-neutral-500 mb-1">
|
||||
Type
|
||||
</span>
|
||||
<span className="capitalize">
|
||||
{selectedSource.type || "Hybrid project"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-neutral-500 mb-1">
|
||||
Gitea Visibility
|
||||
</span>
|
||||
<span>
|
||||
{selectedSource.visibility || "Public in Gitea"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-neutral-500 mb-1">
|
||||
Last Sync
|
||||
</span>
|
||||
<span>
|
||||
{selectedSource.lastSyncedAt
|
||||
? new Date(
|
||||
selectedSource.lastSyncedAt,
|
||||
).toLocaleString()
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-neutral-500 mb-1">
|
||||
Current Revision
|
||||
</span>
|
||||
<span className="font-mono text-[#C5A059]">
|
||||
{selectedSource.lastResolvedCommit?.substring(
|
||||
0,
|
||||
7,
|
||||
) || "HEAD"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest mb-2">
|
||||
Produces
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(selectedSource.produces &&
|
||||
selectedSource.produces.length > 0
|
||||
? selectedSource.produces
|
||||
: ["Discovered Artifacts"]
|
||||
).map((prod) => (
|
||||
<span
|
||||
key={prod}
|
||||
className="px-2.5 py-1 rounded-xs bg-[#1A1A1A] border border-[#262626] text-xs text-neutral-300"
|
||||
>
|
||||
{prod}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeInspectorTab === "health" && (
|
||||
<div className="space-y-4">
|
||||
{selectedSource.health !== "Healthy" &&
|
||||
selectedSource.explainWhy && (
|
||||
<div
|
||||
className={`p-4 rounded-sm border text-xs bg-amber-950/20 border-amber-500/20 text-neutral-300`}
|
||||
>
|
||||
<h3 className="text-amber-400 font-bold mb-1">
|
||||
Not projected
|
||||
</h3>
|
||||
<p className="text-neutral-400 font-bold mb-1 mt-2">
|
||||
Reason:
|
||||
</p>
|
||||
<p>{selectedSource.explainWhy}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: "Repository reachable", pass: true },
|
||||
{ label: "Default branch exists", pass: true },
|
||||
{
|
||||
label: ".labyricorn/site.yml present",
|
||||
pass:
|
||||
selectedSource.configurationState !==
|
||||
"Not Configured",
|
||||
},
|
||||
{
|
||||
label: "Metadata syntax valid",
|
||||
pass: selectedSource.configurationState === "Valid",
|
||||
},
|
||||
{
|
||||
label: "Required content paths exist",
|
||||
pass: selectedSource.health !== "Invalid",
|
||||
},
|
||||
].map((check, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-3 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm text-xs"
|
||||
>
|
||||
<span className="text-neutral-300">
|
||||
{check.label}
|
||||
</span>
|
||||
{check.pass ? (
|
||||
<span className="text-emerald-400 flex items-center space-x-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>Pass</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-rose-400 flex items-center space-x-1">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>Fail</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeInspectorTab === "artifacts" && (
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest block mb-2">
|
||||
Discovered Repository Files & Media
|
||||
</span>
|
||||
<div className="bg-[#0A0A0A] rounded-sm p-4 text-neutral-300 font-mono text-xs space-y-2 border border-[#1F1F1F]">
|
||||
{treeFiles.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between py-1.5 border-b border-[#1A1A1A] last:border-0 hover:bg-[#141414] px-2 rounded-xs"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{f.path.includes(".md") ? (
|
||||
<FileText className="w-3.5 h-3.5 text-[#C5A059]" />
|
||||
) : f.path.includes(".webp") ||
|
||||
f.path.includes(".png") ||
|
||||
f.path.includes(".svg") ? (
|
||||
<Folder className="w-3.5 h-3.5 text-amber-500" />
|
||||
) : (
|
||||
<Code className="w-3.5 h-3.5 text-neutral-500" />
|
||||
)}
|
||||
<span className="text-neutral-200">{f.path}</span>
|
||||
</div>
|
||||
<span className="text-neutral-500 text-[11px]">
|
||||
{f.size}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeInspectorTab === "publishing" && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm space-y-3 text-xs">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-neutral-400">
|
||||
Local Publication
|
||||
</span>
|
||||
<span className="text-emerald-400">Current</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-neutral-400">GitHub Mirror</span>
|
||||
<span className="text-emerald-400">Current</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-neutral-400">
|
||||
Cloudflare Pages
|
||||
</span>
|
||||
<span className="text-emerald-400">Current</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-neutral-500 text-xs">
|
||||
<Folder className="w-12 h-12 mb-4 text-[#1F1F1F]" />
|
||||
<p>Select a repository to open the Inspector.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADD SOURCE MODAL */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-xs z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] max-w-lg w-full p-6 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#1F1F1F] pb-3">
|
||||
<h3 className="text-base font-serif italic text-white">
|
||||
Add New Repository
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="text-neutral-500 hover:text-white text-xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateSource} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Repository ID (Identifier)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. titleflow-core"
|
||||
value={newRepoId}
|
||||
onChange={(e) => setNewRepoId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#1A1A1A] border border-[#2A2A2A] rounded-sm text-white font-mono focus:outline-none focus:border-[#C5A059]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Git Repository URL / Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. [email protected]:labyricorn/repo.git or https://..."
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => setNewRepoUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#1A1A1A] border border-[#2A2A2A] rounded-sm text-white font-mono focus:outline-none focus:border-[#C5A059]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Target Ref / Branch
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRef}
|
||||
onChange={(e) => setNewRef(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#1A1A1A] border border-[#2A2A2A] rounded-sm text-white font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Repository Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. documentation, internal"
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#1A1A1A] border border-[#2A2A2A] rounded-sm text-white font-mono focus:outline-none focus:border-[#C5A059]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block font-semibold text-neutral-300 mb-1">
|
||||
Secret Credential Reference
|
||||
</label>
|
||||
<select
|
||||
value={newCredential}
|
||||
onChange={(e) => setNewCredential(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#1A1A1A] border border-[#2A2A2A] rounded-sm text-white font-mono"
|
||||
>
|
||||
<option value="">None (Public)</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="requiredCheck"
|
||||
checked={newRequired}
|
||||
onChange={(e) => setNewRequired(e.target.checked)}
|
||||
className="rounded border-[#2A2A2A] bg-[#1A1A1A] text-[#C5A059]"
|
||||
/>
|
||||
<label
|
||||
htmlFor="requiredCheck"
|
||||
className="text-neutral-300 font-medium"
|
||||
>
|
||||
Required Source (Production builds fail if unreachable)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-[#1F1F1F]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 border border-[#2A2A2A] text-neutral-300 rounded-sm hover:bg-[#1A1A1A] font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black rounded-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Save Repository
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Eye,
|
||||
Monitor,
|
||||
Tablet,
|
||||
Smartphone,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
|
||||
export const LivePreviewTab: React.FC = () => {
|
||||
const [route, setRoute] = useState("/");
|
||||
const [deviceMode, setDeviceMode] = useState<"desktop" | "tablet" | "mobile">(
|
||||
"desktop",
|
||||
);
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data && e.data.type === "NAVIGATE_LIVE_SITE") {
|
||||
setRoute(e.data.route);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, []);
|
||||
|
||||
const getContainerWidth = () => {
|
||||
switch (deviceMode) {
|
||||
case "mobile":
|
||||
return "max-w-[375px]";
|
||||
case "tablet":
|
||||
return "max-w-[768px]";
|
||||
default:
|
||||
return "w-full";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* HEADER BAR */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-4 shadow-xs flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Eye className="w-5 h-5 text-[#C5A059]" />
|
||||
<div>
|
||||
<h1 className="text-base font-serif italic text-white">
|
||||
Live Static Site Preview
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-400">
|
||||
Rendered statically via Nginx from active local release
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DEVICE MODE TOGGLE */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-[#0A0A0A] p-1 rounded-sm border border-[#1F1F1F] text-xs font-medium text-neutral-400">
|
||||
<button
|
||||
onClick={() => setDeviceMode("desktop")}
|
||||
className={`p-1.5 rounded-xs transition-colors flex items-center space-x-1 ${deviceMode === "desktop" ? "bg-[#1E1E1E] text-[#C5A059] font-semibold" : ""}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Desktop</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeviceMode("tablet")}
|
||||
className={`p-1.5 rounded-xs transition-colors flex items-center space-x-1 ${deviceMode === "tablet" ? "bg-[#1E1E1E] text-[#C5A059] font-semibold" : ""}`}
|
||||
>
|
||||
<Tablet className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Tablet</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeviceMode("mobile")}
|
||||
className={`p-1.5 rounded-xs transition-colors flex items-center space-x-1 ${deviceMode === "mobile" ? "bg-[#1E1E1E] text-[#C5A059] font-semibold" : ""}`}
|
||||
>
|
||||
<Smartphone className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Mobile</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIframeKey((k) => k + 1)}
|
||||
className="p-2 bg-[#141414] hover:bg-[#1F1F1F] text-neutral-300 border border-[#222222] rounded-sm transition-colors"
|
||||
title="Reload Preview"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADDRESS BAR */}
|
||||
<div className="bg-[#0A0A0A] rounded-sm p-2.5 border border-[#1F1F1F] flex items-center space-x-3 text-xs text-neutral-300 font-mono">
|
||||
<div className="flex items-center space-x-1 text-neutral-500">
|
||||
<button
|
||||
onClick={() => setRoute("/")}
|
||||
className="hover:text-white px-1"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIframeKey((k) => k + 1)}
|
||||
className="hover:text-white px-1"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow bg-[#141414] border border-[#222222] rounded-sm px-3 py-1.5 flex items-center space-x-2 text-white">
|
||||
<Globe className="w-3.5 h-3.5 text-[#C5A059] shrink-0" />
|
||||
<span className="text-neutral-500 text-[11px]">
|
||||
http://labyricorn.local
|
||||
</span>
|
||||
<span className="font-semibold text-[#C5A059] truncate">{route}</span>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-emerald-400 bg-emerald-950/40 px-2.5 py-1 rounded-xs border border-emerald-500/20 hidden md:inline-block uppercase tracking-wider font-bold">
|
||||
Nginx Port 80
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* IFRAME CONTAINER */}
|
||||
<div className="flex justify-center bg-[#0A0A0A] p-4 rounded-sm border border-[#1F1F1F] min-h-[600px]">
|
||||
<div
|
||||
className={`w-full ${getContainerWidth()} bg-white rounded-sm shadow-2xl overflow-hidden transition-all border border-[#1F1F1F] flex flex-col`}
|
||||
>
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
src={`/api/live-site/html?route=${encodeURIComponent(route)}`}
|
||||
className="w-full h-[650px] border-0"
|
||||
title="Labyricorn Live Site"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Terminal,
|
||||
GitBranch,
|
||||
Sliders,
|
||||
FileText,
|
||||
Palette,
|
||||
PlayCircle,
|
||||
Server,
|
||||
UploadCloud,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { NginxStatus, BuildRecord, PushTarget } from "../types";
|
||||
|
||||
interface NavbarProps {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
nginxStatus: NginxStatus | null;
|
||||
activeBuild: BuildRecord | null;
|
||||
primaryPushTarget: PushTarget | null;
|
||||
onQuickBuild: () => void;
|
||||
onQuickNginxTest: () => void;
|
||||
isBuilding: boolean;
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
nginxStatus,
|
||||
activeBuild,
|
||||
primaryPushTarget,
|
||||
onQuickBuild,
|
||||
onQuickNginxTest,
|
||||
isBuilding,
|
||||
}) => {
|
||||
const tabs = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: Activity },
|
||||
{ id: "repositories", label: "Repositories", icon: GitBranch },
|
||||
{ id: "explorer", label: "Site Explorer", icon: Eye },
|
||||
{ id: "artifacts", label: "Artifacts", icon: FileText },
|
||||
{ id: "projections", label: "Projections", icon: Palette },
|
||||
{ id: "publishing", label: "Publishing", icon: PlayCircle },
|
||||
{ id: "targets", label: "Publishing Targets", icon: UploadCloud },
|
||||
{ id: "local-site", label: "Local Site", icon: Server },
|
||||
{ id: "system", label: "System", icon: ShieldCheck },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="bg-[#0F0F0F] text-white border-b border-[#1F1F1F] sticky top-0 z-50">
|
||||
{/* TOP BRAND BAR */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-9 h-9 rounded-sm bg-gradient-to-tr from-[#8A6D3B] to-[#C5A059] flex items-center justify-center font-serif italic text-black font-bold text-lg shadow-md shadow-[#C5A059]/10">
|
||||
L
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-serif italic text-lg tracking-tight text-white">
|
||||
Labyricorn
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-[10px] font-mono font-semibold bg-[#C5A059]/10 text-[#C5A059] border border-[#C5A059]/30 rounded-xs uppercase tracking-widest">
|
||||
v1.0.0-MVP
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 hidden sm:block">
|
||||
Git-backed Static Site Control Plane & Nginx Host
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SYSTEM STATUS BADGES & QUICK ACTIONS */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Active Release Badge */}
|
||||
<div className="hidden lg:flex items-center space-x-2 px-3 py-1 bg-[#141414] border border-[#222222] rounded-sm text-xs font-mono text-neutral-300">
|
||||
<span className="text-neutral-500">current -></span>
|
||||
<span className="text-[#C5A059] font-semibold">
|
||||
{activeBuild ? activeBuild.id : "none"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nginx Status Badge */}
|
||||
<div className="hidden md:flex items-center space-x-1.5 px-3 py-1 bg-[#141414] border border-[#222222] rounded-sm text-xs text-neutral-300">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${nginxStatus?.configValid ? "bg-emerald-500" : "bg-amber-500"}`}
|
||||
></span>
|
||||
<span className="font-medium text-neutral-300">
|
||||
Nginx: {nginxStatus?.configValid ? "Active (Port 80)" : "Error"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Push Target Status */}
|
||||
{primaryPushTarget && (
|
||||
<div className="hidden xl:flex items-center space-x-1.5 px-3 py-1 bg-[#141414] border border-[#222222] rounded-sm text-xs text-neutral-300">
|
||||
<span className="text-[#C5A059] font-mono">VPS:</span>
|
||||
<span className="text-neutral-200 font-medium">
|
||||
{primaryPushTarget.host}
|
||||
</span>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QUICK BUILD BUTTON */}
|
||||
<button
|
||||
onClick={onQuickBuild}
|
||||
disabled={isBuilding}
|
||||
className="px-3.5 py-1.5 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-colors shadow-xs flex items-center space-x-1.5 disabled:opacity-50"
|
||||
>
|
||||
<PlayCircle
|
||||
className={`w-3.5 h-3.5 ${isBuilding ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="uppercase tracking-wider text-[11px]">
|
||||
{isBuilding ? "Building..." : "Trigger Build"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NAVIGATION TAB STRIP */}
|
||||
<div className="border-t border-[#1F1F1F] bg-[#0A0A0A]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 overflow-x-auto no-scrollbar">
|
||||
<nav className="flex space-x-1 py-1 text-xs font-medium min-w-max">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center space-x-2 px-3.5 py-2 rounded-sm transition-all ${
|
||||
isActive
|
||||
? "bg-[#181818] text-[#C5A059] border-b-2 border-[#C5A059] font-semibold"
|
||||
: "text-neutral-400 hover:text-white hover:bg-[#141414]"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-3.5 h-3.5 ${isActive ? "text-[#C5A059]" : "text-neutral-500"}`}
|
||||
/>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Server,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Code,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { NginxStatus, BuildRecord } from "../types";
|
||||
|
||||
interface NginxReleasesTabProps {
|
||||
status: NginxStatus;
|
||||
builds: BuildRecord[];
|
||||
onTestConfig: () => Promise<void>;
|
||||
onActivateRelease: (buildId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const NginxReleasesTab: React.FC<NginxReleasesTabProps> = ({
|
||||
status,
|
||||
builds,
|
||||
onTestConfig,
|
||||
onActivateRelease,
|
||||
}) => {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testOutput, setTestOutput] = useState<string | null>(null);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
const activeBuild =
|
||||
builds.find((b) => b.id === status.activeReleaseId) || builds[0];
|
||||
const previousBuild = builds.find(
|
||||
(b) =>
|
||||
b.id !== status.activeReleaseId && !b.isStaged && b.status !== "failed",
|
||||
);
|
||||
|
||||
const handleTest = async () => {
|
||||
setIsTesting(true);
|
||||
setTestOutput(null);
|
||||
try {
|
||||
await onTestConfig();
|
||||
setTestOutput(
|
||||
"nginx: the configuration file /etc/nginx/nginx.conf syntax is ok\nnginx: configuration file /etc/nginx/nginx.conf test is successful",
|
||||
);
|
||||
} catch (err: any) {
|
||||
setTestOutput(`nginx -t failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (buildId: string) => {
|
||||
setIsActivating(true);
|
||||
await onActivateRelease(buildId);
|
||||
setIsActivating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">
|
||||
Local Canonical Site
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-emerald-950/40 text-emerald-400 border border-emerald-500/20">
|
||||
PRD AC-13 & AC-14 Verified
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Hosts the active release locally as the canonical runtime. Performs
|
||||
zero-downtime atomic symlink changes (
|
||||
<code className="text-[#C5A059] font-mono">
|
||||
current -> releases/build-XXXXXX
|
||||
</code>
|
||||
) after configuration validation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting}
|
||||
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all flex items-center space-x-1.5 uppercase tracking-wider"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 ${isTesting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>
|
||||
{isTesting ? "Validating..." : "Validate Server Configuration"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testOutput && (
|
||||
<div className="p-4 bg-[#0A0A0A] text-neutral-200 rounded-sm font-mono text-xs border border-[#1F1F1F] space-y-1">
|
||||
<div className="text-emerald-400 font-bold flex items-center space-x-1.5">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Web Server Syntax Test Result</span>
|
||||
</div>
|
||||
<pre className="text-neutral-300 text-[11px] whitespace-pre-wrap">
|
||||
{testOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SYMLINK RELEASES GRID */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* CURRENT SYMLINK */}
|
||||
<div className="bg-[#111111] rounded-sm border-2 border-[#C5A059] p-6 shadow-xs space-y-4 relative">
|
||||
<span className="absolute -top-3 right-4 bg-[#C5A059] text-black px-3 py-0.5 rounded-xs text-[10px] font-bold uppercase tracking-widest">
|
||||
Active Production
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="w-5 h-5 text-[#C5A059]" />
|
||||
<h2 className="text-base font-bold text-white">current Symlink</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] font-mono text-xs space-y-1">
|
||||
<div className="text-neutral-500 text-[10px] uppercase font-bold tracking-widest">
|
||||
Target Release Directory
|
||||
</div>
|
||||
<div className="text-[#C5A059] font-bold text-sm">
|
||||
/var/lib/labyricorn/site/current
|
||||
</div>
|
||||
<div className="text-neutral-400 text-[11px] pt-1 border-t border-[#1F1F1F]">
|
||||
->{" "}
|
||||
<strong className="text-white">{status.activeReleaseId}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-neutral-400 space-y-1">
|
||||
<div>
|
||||
Hostname:{" "}
|
||||
<strong className="font-mono text-white">
|
||||
{status.serverName}
|
||||
</strong>{" "}
|
||||
(Port {status.listeningPort})
|
||||
</div>
|
||||
<div>
|
||||
Last Reloaded:{" "}
|
||||
<span className="font-mono text-neutral-500">
|
||||
{new Date(status.lastReloadTime).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STAGING SYMLINK */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Layers className="w-5 h-5 text-[#C5A059]" />
|
||||
<h2 className="text-base font-bold text-white">
|
||||
staging Symlink
|
||||
</h2>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-[#1A1A1A] text-[#C5A059] text-[10px] font-bold rounded-xs border border-[#2A2A2A]">
|
||||
Port 8080
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] font-mono text-xs space-y-1">
|
||||
<div className="text-neutral-500 text-[10px] uppercase font-bold tracking-widest">
|
||||
Target Staging Release
|
||||
</div>
|
||||
<div className="text-[#C5A059] font-bold text-sm">
|
||||
/var/lib/labyricorn/site/staging
|
||||
</div>
|
||||
<div className="text-neutral-400 text-[11px] pt-1 border-t border-[#1F1F1F]">
|
||||
->{" "}
|
||||
<strong className="text-white">
|
||||
{status.stagingReleaseId || "None"}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.stagingReleaseId && (
|
||||
<button
|
||||
onClick={() => handleActivate(status.stagingReleaseId!)}
|
||||
className="w-full py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black rounded-sm text-xs font-semibold shadow-sm transition-colors uppercase tracking-wider"
|
||||
>
|
||||
Promote Staging to Production
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PREVIOUS / ROLLBACK SYMLINK */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RotateCcw className="w-5 h-5 text-amber-500" />
|
||||
<h2 className="text-base font-bold text-white">
|
||||
1-Click Rollback Target
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] font-mono text-xs space-y-1">
|
||||
<div className="text-neutral-500 text-[10px] uppercase font-bold tracking-widest">
|
||||
Retained Previous Release
|
||||
</div>
|
||||
<div className="text-amber-400 font-bold text-sm">
|
||||
/var/lib/labyricorn/site/previous
|
||||
</div>
|
||||
<div className="text-neutral-400 text-[11px] pt-1 border-t border-[#1F1F1F]">
|
||||
->{" "}
|
||||
<strong className="text-white">
|
||||
{previousBuild ? previousBuild.id : "build-000002"}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previousBuild && (
|
||||
<button
|
||||
onClick={() => handleActivate(previousBuild.id)}
|
||||
disabled={isActivating}
|
||||
className="w-full py-2 bg-amber-600 hover:bg-amber-500 text-white rounded-sm text-xs font-semibold shadow-sm transition-colors flex items-center justify-center space-x-1 uppercase tracking-wider"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Rollback to {previousBuild.id}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GENERATED NGINX SERVER BLOCK CONFIGURATION */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<h2 className="text-base font-bold text-white">
|
||||
Generated Web Server Configuration (Nginx)
|
||||
</h2>
|
||||
<div className="bg-[#0A0A0A] text-[#C5A059] font-mono text-xs p-4 rounded-sm border border-[#1F1F1F] overflow-x-auto">
|
||||
<pre>
|
||||
<code>{status.serverBlockConfig}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
UploadCloud,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Server,
|
||||
Key,
|
||||
RotateCcw,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { PushTarget, BuildRecord } from "../types";
|
||||
|
||||
interface PushIntegrationsTabProps {
|
||||
targets: PushTarget[];
|
||||
builds: BuildRecord[];
|
||||
onPushToTarget: (
|
||||
targetId: string,
|
||||
buildId: string,
|
||||
) => Promise<{ success: boolean; logs: string[] }>;
|
||||
onTestTarget: (targetId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export const PushIntegrationsTab: React.FC<PushIntegrationsTabProps> = ({
|
||||
targets,
|
||||
builds,
|
||||
onPushToTarget,
|
||||
onTestTarget,
|
||||
}) => {
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<string>(
|
||||
targets[0]?.id || "production-vps",
|
||||
);
|
||||
const [selectedBuildId, setSelectedBuildId] = useState<string>(
|
||||
builds[0]?.id || "build-000003",
|
||||
);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [pushLogs, setPushLogs] = useState<string[]>(
|
||||
targets[0]?.lastLogs || [],
|
||||
);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
|
||||
const target = targets.find((t) => t.id === selectedTargetId) || targets[0];
|
||||
|
||||
const handlePush = async () => {
|
||||
if (!target) return;
|
||||
setIsPushing(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const res = await onPushToTarget(target.id, selectedBuildId);
|
||||
setPushLogs(res.logs || []);
|
||||
} catch (err: any) {
|
||||
setPushLogs([`[ERROR] Push failed: ${err.message}`]);
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!target) return;
|
||||
setIsTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const res = await onTestTarget(target.id);
|
||||
setTestMsg(res.message);
|
||||
} catch (err: any) {
|
||||
setTestMsg(`SSH Test failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">
|
||||
Publishing Targets
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-blue-950/40 text-blue-400 border border-blue-500/20">
|
||||
PRD AC-15 & AC-16 Verified
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Replicate the already-built and approved local projection to remote
|
||||
targets (e.g. GitHub Mirror, Cloudflare Pages).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting}
|
||||
className="px-4 py-2 bg-[#1A1A1A] hover:bg-[#262626] text-white rounded-sm text-xs font-semibold transition-all flex items-center space-x-1.5 border border-[#2A2A2A] uppercase tracking-wider"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 ${isTesting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>Test SSH Connection</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePush}
|
||||
disabled={isPushing}
|
||||
className="px-5 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all flex items-center space-x-2 uppercase tracking-wider"
|
||||
>
|
||||
<UploadCloud
|
||||
className={`w-4 h-4 ${isPushing ? "animate-bounce" : ""}`}
|
||||
/>
|
||||
<span>
|
||||
{isPushing ? "Rsyncing Artifact..." : "Push Selected Artifact"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testMsg && (
|
||||
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 rounded-sm text-xs text-emerald-400 flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span>{testMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TARGET CONFIG & DEPLOYMENT CONTROL */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT COLUMN: TARGET SELECTION & ARTIFACT SELECTOR (5 COLS) */}
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<h2 className="text-[10px] font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Configured Target
|
||||
</h2>
|
||||
|
||||
{target ? (
|
||||
<div className="p-4 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-3 font-mono text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-white text-sm">
|
||||
{target.name}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-emerald-950/40 text-emerald-400 border border-emerald-500/20 rounded-xs text-[10px] font-bold uppercase">
|
||||
{target.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{target.explainWhy && target.status === "skipped" && (
|
||||
<div className="mt-2 p-3 bg-amber-950/20 border border-amber-900/40 rounded-sm">
|
||||
<p className="text-amber-400 font-bold mb-1 font-sans">
|
||||
Target skipped
|
||||
</p>
|
||||
<p className="text-neutral-300 font-sans">
|
||||
{target.explainWhy}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 text-neutral-400 text-[11px] mt-3">
|
||||
<div>
|
||||
Host: <strong className="text-white">{target.host}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Remote Path:{" "}
|
||||
<code className="text-[#C5A059]">{target.remotePath}</code>
|
||||
</div>
|
||||
<div>
|
||||
Secret Reference:{" "}
|
||||
<code className="text-amber-400">{target.credential}</code>
|
||||
</div>
|
||||
<div>
|
||||
Symlink:{" "}
|
||||
<code className="text-neutral-300">
|
||||
{target.currentLink}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-[#1F1F1F] text-[11px]">
|
||||
<span>
|
||||
Last Deployed Build:{" "}
|
||||
<strong className="text-[#C5A059] font-bold">
|
||||
{target.lastDeployedBuildId || "None"}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ARTIFACT SELECTOR */}
|
||||
<div className="space-y-2 pt-2">
|
||||
<label className="block text-[10px] font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Select Local Release to Push
|
||||
</label>
|
||||
<select
|
||||
value={selectedBuildId}
|
||||
onChange={(e) => setSelectedBuildId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#141414] border border-[#222222] rounded-sm text-xs text-white font-mono font-semibold focus:outline-none focus:border-[#C5A059]"
|
||||
>
|
||||
{builds
|
||||
.filter((b) => b.status !== "failed")
|
||||
.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.id} - SHA: {b.artifactChecksum.substring(0, 12)}... (
|
||||
{b.isActiveLocal ? "Active Local" : "Retained"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Only already-compiled local releases are pushed. No remote
|
||||
compilation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: RSYNC LOG TERMINAL & REMOTE VERIFICATION (7 COLS) */}
|
||||
<div className="lg:col-span-7 bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#1F1F1F] pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Terminal className="w-5 h-5 text-[#C5A059]" />
|
||||
<h2 className="text-base font-bold text-white">
|
||||
Target Synchronization Logs
|
||||
</h2>
|
||||
</div>
|
||||
<span className="px-2.5 py-0.5 bg-[#1A1A1A] text-neutral-300 text-xs font-mono font-semibold rounded-xs border border-[#262626]">
|
||||
Protocol: builtin:ssh-rsync
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0A0A0A] text-neutral-200 font-mono text-xs rounded-sm p-4 border border-[#1F1F1F] space-y-2 max-h-[350px] overflow-y-auto">
|
||||
{pushLogs.length > 0 ? (
|
||||
pushLogs.map((log, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="py-0.5 border-b border-[#141414] last:border-0 text-neutral-300"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-neutral-500 py-8 text-center">
|
||||
No push execution logs yet. Click 'Push Selected Artifact'.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-[#1F1F1F] flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>
|
||||
Remote Deployed SHA:{" "}
|
||||
<strong className="text-emerald-400 font-mono">
|
||||
{target?.remoteChecksum?.substring(0, 16) || "None"}...
|
||||
</strong>
|
||||
</span>
|
||||
<span className="text-emerald-400 font-medium">
|
||||
✓ AC-16 Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import React, { useState } from "react";
|
||||
import { GitSource, ContentItem, SiteConfig } from "../types";
|
||||
import {
|
||||
Globe,
|
||||
LayoutTemplate,
|
||||
FileText,
|
||||
Download,
|
||||
MessageSquare,
|
||||
Rocket,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
Search,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SiteExplorerTabProps {
|
||||
sources: GitSource[];
|
||||
siteConfig: SiteConfig | null;
|
||||
}
|
||||
|
||||
export const SiteExplorerTab: React.FC<SiteExplorerTabProps> = ({
|
||||
sources,
|
||||
siteConfig,
|
||||
}) => {
|
||||
const defaultSections = siteConfig?.navigation || [];
|
||||
const [selectedSection, setSelectedSection] = useState<string>(
|
||||
defaultSections[0]?.id || "home",
|
||||
);
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
switch (iconName) {
|
||||
case "Globe":
|
||||
return Globe;
|
||||
case "LayoutTemplate":
|
||||
return LayoutTemplate;
|
||||
case "FileText":
|
||||
return FileText;
|
||||
case "FolderOpen":
|
||||
return FolderOpen;
|
||||
case "MessageSquare":
|
||||
return MessageSquare;
|
||||
case "Download":
|
||||
return Download;
|
||||
default:
|
||||
return Globe;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-6 h-[calc(100vh-180px)] min-h-[600px]">
|
||||
{/* SIDEBAR: Website Structure */}
|
||||
<div className="w-full md:w-64 flex flex-col bg-[#111111] border border-[#1F1F1F] rounded-sm overflow-hidden shrink-0">
|
||||
<div className="p-4 border-b border-[#1F1F1F]">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-white mb-3">
|
||||
Website Structure
|
||||
</h2>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sections..."
|
||||
className="w-full bg-[#0A0A0A] border border-[#1F1F1F] text-white text-xs pl-9 pr-3 py-2 rounded-sm focus:outline-none focus:border-[#C5A059]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1 no-scrollbar">
|
||||
{defaultSections.map((section) => {
|
||||
const Icon = getIcon(section.iconName);
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setSelectedSection(section.id as any)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-sm flex items-center justify-between transition-colors ${
|
||||
selectedSection === section.id
|
||||
? "bg-[#181818] border border-[#C5A059]/30 text-[#C5A059]"
|
||||
: "text-neutral-400 hover:bg-[#141414] hover:text-white border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 truncate">
|
||||
<Icon
|
||||
className={`w-4 h-4 shrink-0 ${selectedSection === section.id ? "text-[#C5A059]" : "text-neutral-500"}`}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{section.label}
|
||||
</span>
|
||||
</div>
|
||||
{selectedSection === section.id && (
|
||||
<ChevronRight className="w-4 h-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MAIN CONTENT AREA */}
|
||||
<div className="flex-1 bg-[#111111] border border-[#1F1F1F] rounded-sm flex flex-col overflow-hidden">
|
||||
{/* Section Header */}
|
||||
<div className="p-6 border-b border-[#1F1F1F]">
|
||||
<div className="flex items-center space-x-2 text-xs font-mono text-neutral-500 mb-2">
|
||||
<span>Generated URL</span>
|
||||
<span>•</span>
|
||||
<span className="text-[#C5A059]">
|
||||
{defaultSections.find((s) => s.id === selectedSection)?.route}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif italic text-white">
|
||||
{defaultSections.find((s) => s.id === selectedSection)?.label}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 bg-[#0A0A0A]">
|
||||
<div className="space-y-4 max-w-2xl text-sm text-neutral-400 leading-relaxed">
|
||||
<p>
|
||||
This section is projected from artifacts discovered across
|
||||
configured repositories. The layout and content are generated
|
||||
deterministically based on the artifacts'{" "}
|
||||
<code className="bg-[#1A1A1A] px-1 py-0.5 rounded text-[#C5A059] font-mono text-xs">
|
||||
.labyricorn/site.yml
|
||||
</code>{" "}
|
||||
configurations.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 p-4 border border-[#1F1F1F] bg-[#0A0A0A] rounded-sm">
|
||||
<h3 className="text-white font-semibold mb-4 uppercase tracking-widest text-[10px]">
|
||||
Configuration Trace
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 font-mono text-xs relative before:absolute before:inset-0 before:ml-[11px] before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-[#1F1F1F] before:to-transparent">
|
||||
{/* Trace Step 1: Definition */}
|
||||
<div className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-[#111111] border border-[#1F1F1F] text-[#C5A059] shadow-sm z-10 shrink-0 md:order-1 md:group-odd:-ml-3 md:group-even:-mr-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#C5A059]"></span>
|
||||
</div>
|
||||
<div className="w-[calc(100%-2rem)] md:w-[calc(50%-1.5rem)] p-3 rounded-sm border border-[#1F1F1F] bg-[#111111] shadow-xs">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-neutral-500 text-[10px] uppercase">
|
||||
Defined In
|
||||
</span>
|
||||
<span className="text-neutral-300">Site Definition</span>
|
||||
</div>
|
||||
<div className="font-semibold text-[#C5A059]">
|
||||
Navigation Entry:{" "}
|
||||
{
|
||||
defaultSections.find((s) => s.id === selectedSection)
|
||||
?.label
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trace Step 2: Content Model */}
|
||||
<div className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-[#111111] border border-[#1F1F1F] text-[#C5A059] shadow-sm z-10 shrink-0 md:order-1 md:group-odd:-ml-3 md:group-even:-mr-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#C5A059]"></span>
|
||||
</div>
|
||||
<div className="w-[calc(100%-2rem)] md:w-[calc(50%-1.5rem)] p-3 rounded-sm border border-[#1F1F1F] bg-[#111111] shadow-xs">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-neutral-500 text-[10px] uppercase">
|
||||
Uses Content Model
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-neutral-300">
|
||||
{defaultSections.find((s) => s.id === selectedSection)
|
||||
?.contentModel || "None"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trace Step 3: Style Config */}
|
||||
<div className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-[#111111] border border-[#1F1F1F] text-[#C5A059] shadow-sm z-10 shrink-0 md:order-1 md:group-odd:-ml-3 md:group-even:-mr-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#C5A059]"></span>
|
||||
</div>
|
||||
<div className="w-[calc(100%-2rem)] md:w-[calc(50%-1.5rem)] p-3 rounded-sm border border-[#1F1F1F] bg-[#111111] shadow-xs">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-neutral-500 text-[10px] uppercase">
|
||||
Uses Style Configuration
|
||||
</span>
|
||||
<span className="text-[#C5A059] text-[10px]">
|
||||
Site Default
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-neutral-300">
|
||||
{siteConfig?.contentModels?.find(
|
||||
(m) =>
|
||||
m.id ===
|
||||
defaultSections.find((s) => s.id === selectedSection)
|
||||
?.contentModel,
|
||||
)?.defaultStyleConfig || "standard-style"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trace Step 4: Template */}
|
||||
<div className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-[#111111] border border-[#1F1F1F] text-[#C5A059] shadow-sm z-10 shrink-0 md:order-1 md:group-odd:-ml-3 md:group-even:-mr-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#C5A059]"></span>
|
||||
</div>
|
||||
<div className="w-[calc(100%-2rem)] md:w-[calc(50%-1.5rem)] p-3 rounded-sm border border-[#1F1F1F] bg-[#111111] shadow-xs">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-neutral-500 text-[10px] uppercase">
|
||||
Uses Template
|
||||
</span>
|
||||
<span className="text-[#C5A059] text-[10px]">
|
||||
Theme Package
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-neutral-300">
|
||||
{defaultSections.find((s) => s.id === selectedSection)
|
||||
?.contentModel || "generic"}
|
||||
/index.html
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trace Step 5: Route */}
|
||||
<div className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-[#C5A059] text-black shadow-sm z-10 shrink-0 md:order-1 md:group-odd:-ml-3 md:group-even:-mr-3">
|
||||
<span className="w-2 h-2 rounded-full bg-black"></span>
|
||||
</div>
|
||||
<div className="w-[calc(100%-2rem)] md:w-[calc(50%-1.5rem)] p-3 rounded-sm border border-[#C5A059]/50 bg-[#C5A059]/10 shadow-xs">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-neutral-300 text-[10px] uppercase font-bold">
|
||||
Generated Route
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[#C5A059] font-bold">
|
||||
{
|
||||
defaultSections.find((s) => s.id === selectedSection)
|
||||
?.route
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 border border-[#1F1F1F] bg-[#111111] rounded-sm">
|
||||
<h3 className="text-white font-semibold mb-2">
|
||||
Publishing Targets
|
||||
</h3>
|
||||
<ul className="space-y-2 font-mono text-xs">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-neutral-500">Local Site</span>
|
||||
<span className="text-emerald-400">Current</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-neutral-500">GitHub Mirror</span>
|
||||
<span className="text-emerald-400">Current</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Sliders,
|
||||
Folder,
|
||||
GitBranch,
|
||||
FileText,
|
||||
Layers,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
Code,
|
||||
ShieldAlert,
|
||||
Edit2,
|
||||
} from "lucide-react";
|
||||
import { StyleConfigPackage, StyleConfigInstance, GitSource } from "../types";
|
||||
|
||||
interface StyleInstancesTabProps {
|
||||
packages: StyleConfigPackage[];
|
||||
instances: StyleConfigInstance[];
|
||||
sources: GitSource[];
|
||||
onUpdateInstance: (inst: StyleConfigInstance) => void;
|
||||
}
|
||||
|
||||
export const StyleInstancesTab: React.FC<StyleInstancesTabProps> = ({
|
||||
packages,
|
||||
instances,
|
||||
sources,
|
||||
onUpdateInstance,
|
||||
}) => {
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string>(
|
||||
instances[0]?.id || "devlogs",
|
||||
);
|
||||
const selectedInstance = instances.find((i) => i.id === selectedInstanceId);
|
||||
const selectedPackage = packages.find(
|
||||
(p) =>
|
||||
p.id === (selectedInstance?.uses.replace("builtin:", "") || "devlogs"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">
|
||||
Style-Config Packages & Instances
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-[#C5A059]/10 text-[#C5A059] border border-[#C5A059]/30">
|
||||
PRD AC-03 & AC-04 Compliant
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Reusable presentation packages and site-specific instances defining
|
||||
paths (e.g.{" "}
|
||||
<code className="bg-[#1A1A1A] px-1 rounded text-[#C5A059] font-mono">
|
||||
/.devlogs
|
||||
</code>
|
||||
), repository overrides, media namespaces, and route patterns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* INSTANCE SELECTOR & PACKAGES SUMMARY */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT LIST: STYLE INSTANCES (4 COLS) */}
|
||||
<div className="lg:col-span-4 space-y-3">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest block px-1">
|
||||
Configured Style Instances ({instances.length})
|
||||
</span>
|
||||
|
||||
{instances.map((inst) => {
|
||||
const isSelected = inst.id === selectedInstanceId;
|
||||
const pkg = packages.find(
|
||||
(p) => p.id === inst.uses.replace("builtin:", ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={inst.id}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
className={`p-4 rounded-sm border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? "bg-[#181818] border-[#C5A059] shadow-xs"
|
||||
: "bg-[#111111] border-[#1F1F1F] hover:border-[#2A2A2A]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-bold text-white text-sm font-mono">
|
||||
{inst.id}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-[10px] font-semibold bg-[#1A1A1A] text-neutral-300 rounded-xs border border-[#262626]">
|
||||
{inst.uses}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-neutral-400 font-mono">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-neutral-500">Default Path:</span>
|
||||
<span className="text-[#C5A059] font-semibold">
|
||||
{inst.defaultPath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-neutral-500">
|
||||
Participating Repos:
|
||||
</span>
|
||||
<span className="text-neutral-200 font-semibold">
|
||||
{inst.repositories.length} Repos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-2 border-t border-[#1F1F1F] flex items-center justify-between text-[11px] text-neutral-500">
|
||||
<span>
|
||||
Routing:{" "}
|
||||
<code className="text-neutral-300 font-mono">
|
||||
{inst.itemPattern}
|
||||
</code>
|
||||
</span>
|
||||
<span className="text-emerald-400 font-medium">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* BUILT-IN PACKAGES REFERENCE */}
|
||||
<div className="mt-6 bg-[#0A0A0A] rounded-sm p-5 text-white border border-[#1F1F1F] space-y-3">
|
||||
<h3 className="text-[10px] font-bold text-neutral-400 uppercase tracking-widest flex items-center space-x-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-[#C5A059]" />
|
||||
<span>Built-In Standard Library ({packages.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-2 text-xs">
|
||||
{packages.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="p-2.5 bg-[#141414] rounded-sm border border-[#222222]"
|
||||
>
|
||||
<div className="flex items-center justify-between font-mono font-semibold text-neutral-200 text-[11px]">
|
||||
<span>{p.name}</span>
|
||||
<code className="text-[#C5A059] font-mono">
|
||||
builtin:{p.id}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-500 mt-1 line-clamp-1">
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: SELECTED INSTANCE CONFIGURATION DETAILS (8 COLS) */}
|
||||
<div className="lg:col-span-8 bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-6">
|
||||
{selectedInstance ? (
|
||||
<>
|
||||
{/* INSTANCE HEADER & PACKAGE REFERENCE */}
|
||||
<div className="border-b border-[#1F1F1F] pb-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sliders className="w-5 h-5 text-[#C5A059]" />
|
||||
<h2 className="text-lg font-bold text-white font-mono">
|
||||
{selectedInstance.id} Instance
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">
|
||||
Package:{" "}
|
||||
<strong className="text-neutral-200 font-mono">
|
||||
{selectedInstance.uses}
|
||||
</strong>{" "}
|
||||
({selectedPackage?.description})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="px-3 py-1 bg-[#1A1A1A] text-[#C5A059] rounded-xs text-xs font-mono font-semibold border border-[#2A2A2A]">
|
||||
Route: {selectedInstance.aggregateRoute}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CORE PATHS & ROUTING RULES */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div className="p-4 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-2">
|
||||
<span className="font-bold text-neutral-300 block text-[10px] uppercase tracking-widest">
|
||||
Default Content Path
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Folder className="w-4 h-4 text-[#C5A059]" />
|
||||
<code className="font-mono text-[#C5A059] bg-[#141414] px-2 py-1 rounded-xs border border-[#222222] text-xs font-semibold">
|
||||
{selectedInstance.defaultPath}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-neutral-500 text-[11px]">
|
||||
Scans Markdown files under this relative path across
|
||||
participating Git repos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-2">
|
||||
<span className="font-bold text-neutral-300 block text-[10px] uppercase tracking-widest">
|
||||
Media Public Namespace
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Folder className="w-4 h-4 text-amber-500" />
|
||||
<code className="font-mono text-amber-400 bg-[#141414] px-2 py-1 rounded-xs border border-[#222222] text-xs font-semibold">
|
||||
{selectedInstance.mediaPublicNamespace}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-neutral-500 text-[11px]">
|
||||
Prevents media path collisions across repositories when
|
||||
serving under{" "}
|
||||
<code className="text-neutral-400 font-mono">/media</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PARTICIPATING REPOSITORIES & REPO ENTRY DEFAULTS */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[10px] font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Participating Repositories & Entry Defaults
|
||||
</h3>
|
||||
<span className="text-xs text-[#C5A059] font-medium">
|
||||
Configured in Style Instance (AC-04)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedInstance.repositories.map((repo, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 rounded-sm border border-[#1F1F1F] bg-[#0A0A0A] space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<GitBranch className="w-4 h-4 text-[#C5A059]" />
|
||||
<span className="font-bold text-white font-mono text-sm">
|
||||
{repo.source}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 font-mono">
|
||||
Path Override:{" "}
|
||||
<code className="text-neutral-200 bg-[#141414] px-1.5 py-0.5 rounded-xs border border-[#222222]">
|
||||
{repo.path || selectedInstance.defaultPath}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* REPO DEFAULTS GRID */}
|
||||
<div className="p-3 bg-[#111111] rounded-sm border border-[#1F1F1F] text-xs space-y-1 font-mono">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest block mb-1">
|
||||
Repository Level Entry Defaults
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-neutral-300">
|
||||
<div>
|
||||
<span className="text-neutral-500">
|
||||
project_id:
|
||||
</span>{" "}
|
||||
<strong className="text-[#C5A059]">
|
||||
{repo.defaults?.projectId || repo.source}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-neutral-500">
|
||||
project_name:
|
||||
</span>{" "}
|
||||
<strong className="text-white">
|
||||
{repo.defaults?.projectName || "Default"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-neutral-500">
|
||||
project_url:
|
||||
</span>{" "}
|
||||
<strong className="text-white">
|
||||
{repo.defaults?.projectUrl || "/"}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SUPPORTED PACKAGE VIEWS */}
|
||||
{selectedPackage && (
|
||||
<div className="border-t border-[#1F1F1F] pt-4 space-y-3">
|
||||
<h3 className="text-[10px] font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Available Views in Package
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
{Object.entries(selectedPackage.views).map(
|
||||
([viewKey, viewVal]: [string, any]) => (
|
||||
<div
|
||||
key={viewKey}
|
||||
className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between font-mono font-semibold text-white">
|
||||
<span className="text-[#C5A059]">{viewKey}</span>
|
||||
<span className="text-[10px] text-neutral-500">
|
||||
{viewVal.template}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-neutral-400 text-[11px]">
|
||||
{viewVal.description}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500 text-xs">
|
||||
Select a style instance to inspect configuration.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Palette,
|
||||
Folder,
|
||||
FileCode,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Layout,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { ThemeConfig } from "../types";
|
||||
|
||||
interface ThemeTabProps {
|
||||
theme: ThemeConfig;
|
||||
onValidateTheme: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
theme,
|
||||
onValidateTheme,
|
||||
}) => {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
|
||||
const handleValidate = async () => {
|
||||
setIsValidating(true);
|
||||
await onValidateTheme();
|
||||
setStatusMsg(
|
||||
"Theme /.theme validated successfully. All required templates, CSS resets, and package supports are valid.",
|
||||
);
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HEADER */}
|
||||
<div className="bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-serif italic text-white">
|
||||
Theme Management
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-[#C5A059]/10 text-[#C5A059] border border-[#C5A059]/30">
|
||||
PRD AC-10 Standard Location: /.theme
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Loads active theme from{" "}
|
||||
<code className="text-[#C5A059] font-mono">/.theme</code>, validates{" "}
|
||||
<code className="text-neutral-300 font-mono">theme.yaml</code>,
|
||||
templates, assets, and supported package layouts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={isValidating}
|
||||
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black font-semibold rounded-sm text-xs transition-all flex items-center space-x-1.5 uppercase tracking-wider"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 ${isValidating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span>
|
||||
{isValidating ? "Validating Theme..." : "Validate Theme Manifest"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 rounded-sm text-xs text-emerald-400 flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span>{statusMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* THEME STRUCTURE GRID */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT COLUMN: THEME METADATA & MANIFEST (5 COLS) */}
|
||||
<div className="lg:col-span-5 bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#1F1F1F] pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Palette className="w-5 h-5 text-[#C5A059]" />
|
||||
<h2 className="text-base font-bold text-white">{theme.name}</h2>
|
||||
</div>
|
||||
<span className="px-2.5 py-0.5 bg-[#1A1A1A] text-neutral-300 rounded-xs font-mono text-xs font-semibold border border-[#262626]">
|
||||
v{theme.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-1 font-mono">
|
||||
<span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block">
|
||||
Theme Root Location
|
||||
</span>
|
||||
<span className="text-[#C5A059] font-bold text-xs">
|
||||
{theme.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-1 font-mono">
|
||||
<span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block">
|
||||
Theme ID
|
||||
</span>
|
||||
<span className="text-white font-semibold">{theme.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-2">
|
||||
<span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block font-mono">
|
||||
Supported Style Packages ({theme.supportsPackages.length})
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{theme.supportsPackages.map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="px-2 py-0.5 bg-[#1A1A1A] text-[#C5A059] border border-[#2A2A2A] rounded-xs font-mono text-[10px]"
|
||||
>
|
||||
builtin:{pkg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: TEMPLATES & ASSETS INSPECTOR (7 COLS) */}
|
||||
<div className="lg:col-span-7 bg-[#111111] rounded-sm border border-[#1F1F1F] p-6 shadow-xs space-y-6">
|
||||
<h2 className="text-base font-bold text-white">
|
||||
Theme Assets & Templates Catalog
|
||||
</h2>
|
||||
|
||||
{/* TEMPLATES LIST */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest block">
|
||||
Required Templates
|
||||
</span>
|
||||
<div className="bg-[#0A0A0A] rounded-sm p-4 text-neutral-200 font-mono text-xs space-y-2 border border-[#1F1F1F]">
|
||||
{Object.entries(theme.templates).map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between py-1 border-b border-[#1A1A1A] last:border-0"
|
||||
>
|
||||
<span className="text-[#C5A059] font-semibold">{key}</span>
|
||||
<span className="text-neutral-400 text-[11px]">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STYLES & SCRIPTS */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs font-mono">
|
||||
<div className="p-3.5 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm space-y-1.5">
|
||||
<span className="font-bold text-neutral-400 uppercase tracking-widest text-[10px] block">
|
||||
CSS Style Assets
|
||||
</span>
|
||||
{theme.styles.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className="text-neutral-300 text-[11px] flex items-center space-x-1.5"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#C5A059]"></span>
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 bg-[#0A0A0A] border border-[#1F1F1F] rounded-sm space-y-1.5">
|
||||
<span className="font-bold text-neutral-400 uppercase tracking-widest text-[10px] block">
|
||||
JS Script Assets
|
||||
</span>
|
||||
{theme.scripts.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className="text-neutral-300 text-[11px] flex items-center space-x-1.5"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user