Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out: - Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim) - Delete app/api/projects/[projectId]/workspace/route.ts and app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning) - Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts - Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/ theiaError from app/api/projects/create/route.ts response - Remove Theia callbackUrl branch in app/auth/page.tsx - Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx - Drop theiaWorkspaceUrl from deployment/page.tsx Project type - Strip Theia IDE line + theia-code-os from advisor + agent-chat context strings - Scrub Theia mention from lib/auth/workspace-auth.ts comment P5.1 (custom apex domains + DNS): - lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS XML auth, Cloud DNS plumbing - scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS + prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup In-progress (Justine onboarding/build, MVP setup, agent telemetry): - New (justine)/stories, project (home) layouts, mvp-setup, run, tasks routes + supporting components - Project shell + sidebar + nav refactor for the Stackless palette - Agent session API hardening (sessions, events, stream, approve, retry, stop) + atlas-chat, advisor, design-surfaces refresh - New scripts/sync-db-url-from-coolify.mjs + scripts/prisma-db-push.mjs + docker-compose.local-db.yml for local Prisma workflows - lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts - Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel Made-with: Cursor
This commit is contained in:
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project home scaffold.
|
||||
*
|
||||
* Mirrors the /[workspace]/projects scaffold: VIBNSidebar on the left,
|
||||
* cream main area on the right. Used only for the project home page
|
||||
* (`/{workspace}/project/{id}`) — sub-routes use the (workspace) group
|
||||
* with the ProjectShell tab nav instead.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Toaster } from "sonner";
|
||||
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
|
||||
|
||||
export default function ProjectHomeLayout({ children }: { children: ReactNode }) {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
<main style={{ flex: 1, overflow: "auto" }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<ProjectAssociationPrompt workspace={workspace} />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project home page.
|
||||
*
|
||||
* Sits between the projects list and the AI interview. Gives users two
|
||||
* simplified entry tiles — Code (their Gitea repo) and Infrastructure
|
||||
* (their Coolify deployment) — plus a quiet "Continue setup" link if
|
||||
* the discovery interview isn't done.
|
||||
*
|
||||
* Styled to match the production "ink & parchment" design:
|
||||
* Newsreader serif headings, Outfit sans body, warm beige borders,
|
||||
* solid black CTAs. No indigo. No gradients.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||
import {
|
||||
ArrowRight,
|
||||
Code2,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
Rocket,
|
||||
} from "lucide-react";
|
||||
|
||||
// ── Design tokens (mirrors the prod ink & parchment palette) ─────────
|
||||
const INK = {
|
||||
fontSerif: '"Newsreader", "Lora", Georgia, serif',
|
||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
fontMono: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
ink: "#1a1a1a",
|
||||
ink2: "#2c2c2a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
stone: "#b5b0a6",
|
||||
border: "#e8e4dc",
|
||||
borderHover: "#d0ccc4",
|
||||
cardBg: "#fff",
|
||||
pageBg: "#f7f4ee",
|
||||
shadow: "0 1px 2px #1a1a1a05",
|
||||
shadowHover: "0 2px 8px #1a1a1a0a",
|
||||
iconWrapBg: "#1a1a1a08",
|
||||
} as const;
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
productName?: string;
|
||||
name?: string;
|
||||
productVision?: string;
|
||||
description?: string;
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
stage?: "discovery" | "architecture" | "building" | "active";
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
discoveryPhase?: number;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface FileTreeItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "dir";
|
||||
}
|
||||
|
||||
interface PreviewApp {
|
||||
name: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function ProjectHomePage() {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
const { status: authStatus } = useSession();
|
||||
|
||||
const [project, setProject] = useState<ProjectSummary | null>(null);
|
||||
const [projectLoading, setProjectLoading] = useState(true);
|
||||
|
||||
const [files, setFiles] = useState<FileTreeItem[] | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(true);
|
||||
|
||||
const [apps, setApps] = useState<PreviewApp[]>([]);
|
||||
const [appsLoading, setAppsLoading] = useState(true);
|
||||
|
||||
const ready = useMemo(
|
||||
() => isClientDevProjectBypass() || authStatus === "authenticated",
|
||||
[authStatus]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
if (authStatus === "unauthenticated") setProjectLoading(false);
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${projectId}`, { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project ?? null))
|
||||
.catch(() => {})
|
||||
.finally(() => setProjectLoading(false));
|
||||
}, [ready, authStatus, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
fetch(`/api/projects/${projectId}/file?path=`, { credentials: "include" })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => {
|
||||
if (d?.type === "dir" && Array.isArray(d.items)) {
|
||||
setFiles(d.items as FileTreeItem[]);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setFiles([]))
|
||||
.finally(() => setFilesLoading(false));
|
||||
}, [ready, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
fetch(`/api/projects/${projectId}/preview-url`, { credentials: "include" })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => setApps(Array.isArray(d?.apps) ? d.apps : []))
|
||||
.catch(() => {})
|
||||
.finally(() => setAppsLoading(false));
|
||||
}, [ready, projectId]);
|
||||
|
||||
const projectName = project?.productName || project?.name || "Untitled project";
|
||||
const projectDesc = project?.productVision || project?.description;
|
||||
const stage = project?.stage ?? "discovery";
|
||||
const interviewIncomplete = stage === "discovery";
|
||||
const liveApp = apps.find(a => a.url) ?? apps[0] ?? null;
|
||||
|
||||
if (projectLoading) {
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={centeredFiller}>
|
||||
<Loader2 className="animate-spin" size={22} style={{ color: INK.stone }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={{ ...centeredFiller, color: INK.muted, fontSize: 14, fontFamily: INK.fontSans }}>
|
||||
Project not found.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={pageInner}>
|
||||
{/* ── Hero ─────────────────────────────────────────────── */}
|
||||
<header style={heroStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={eyebrow}>Project</div>
|
||||
<h1 style={heroTitle}>{projectName}</h1>
|
||||
{projectDesc && <p style={heroDesc}>{projectDesc}</p>}
|
||||
</div>
|
||||
<StagePill stage={stage} />
|
||||
</header>
|
||||
|
||||
{/* ── Continue setup link (quiet, only when in discovery) ── */}
|
||||
{interviewIncomplete && (
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/overview`}
|
||||
style={continueRow}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<span style={continueDot} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={continueTitle}>Continue setup</div>
|
||||
<div style={continueSub}>
|
||||
Pick up the AI interview where you left off.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} style={{ color: INK.ink, flexShrink: 0 }} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* ── Two big tiles ────────────────────────────────────── */}
|
||||
<div style={tileGrid}>
|
||||
<CodeTile
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
files={files}
|
||||
loading={filesLoading}
|
||||
giteaRepo={project.giteaRepo}
|
||||
/>
|
||||
<InfraTile
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
app={liveApp}
|
||||
loading={appsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Tiles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CodeTile({
|
||||
workspace,
|
||||
projectId,
|
||||
files,
|
||||
loading,
|
||||
giteaRepo,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
files: FileTreeItem[] | null;
|
||||
loading: boolean;
|
||||
giteaRepo?: string;
|
||||
}) {
|
||||
const items = files ?? [];
|
||||
const dirCount = items.filter(i => i.type === "dir").length;
|
||||
const fileCount = items.filter(i => i.type === "file").length;
|
||||
const previewItems = items.slice(0, 6);
|
||||
|
||||
return (
|
||||
<Link href={`/${workspace}/project/${projectId}/code`} style={tileLink}>
|
||||
<article
|
||||
style={tileCard}
|
||||
onMouseEnter={hoverEnter}
|
||||
onMouseLeave={hoverLeave}
|
||||
>
|
||||
<header style={tileHeader}>
|
||||
<span style={tileIconWrap}>
|
||||
<Code2 size={16} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={tileTitle}>Code</h2>
|
||||
<p style={tileSubtitle}>What the AI is building, file by file.</p>
|
||||
</div>
|
||||
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||
</header>
|
||||
|
||||
<div style={tileBody}>
|
||||
{loading ? (
|
||||
<TileLoader label="Reading repository…" />
|
||||
) : items.length === 0 ? (
|
||||
<TileEmpty
|
||||
icon={<Folder size={18} />}
|
||||
title="No files yet"
|
||||
subtitle={
|
||||
giteaRepo
|
||||
? "Your repository is empty. The AI will commit the first files when you start building."
|
||||
: "This project doesn't have a repository yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={tileMetaRow}>
|
||||
<Metric label="Folders" value={dirCount} />
|
||||
<Metric label="Files" value={fileCount} />
|
||||
</div>
|
||||
<ul style={fileList}>
|
||||
{previewItems.map(item => (
|
||||
<li key={item.path} style={fileRow}>
|
||||
<span style={fileIconWrap}>
|
||||
{item.type === "dir" ? (
|
||||
<Folder size={13} />
|
||||
) : (
|
||||
<FileText size={13} />
|
||||
)}
|
||||
</span>
|
||||
<span style={fileName}>{item.name}</span>
|
||||
<span style={fileType}>
|
||||
{item.type === "dir" ? "folder" : ext(item.name)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{items.length > previewItems.length && (
|
||||
<div style={tileMore}>
|
||||
+{items.length - previewItems.length} more
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function InfraTile({
|
||||
workspace,
|
||||
projectId,
|
||||
app,
|
||||
loading,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
app: PreviewApp | null;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const status = app?.status?.toLowerCase() ?? "unknown";
|
||||
const isLive = !!app?.url && (status.includes("running") || status.includes("healthy"));
|
||||
const isBuilding = status.includes("queued") || status.includes("in_progress") || status.includes("starting");
|
||||
|
||||
return (
|
||||
<Link href={`/${workspace}/project/${projectId}/infrastructure`} style={tileLink}>
|
||||
<article
|
||||
style={tileCard}
|
||||
onMouseEnter={hoverEnter}
|
||||
onMouseLeave={hoverLeave}
|
||||
>
|
||||
<header style={tileHeader}>
|
||||
<span style={tileIconWrap}>
|
||||
<Rocket size={16} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={tileTitle}>Infrastructure</h2>
|
||||
<p style={tileSubtitle}>What's live and how it's running.</p>
|
||||
</div>
|
||||
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||
</header>
|
||||
|
||||
<div style={tileBody}>
|
||||
{loading ? (
|
||||
<TileLoader label="Checking deployment…" />
|
||||
) : !app ? (
|
||||
<TileEmpty
|
||||
icon={<Rocket size={18} />}
|
||||
title="Nothing is live yet"
|
||||
subtitle="The AI will deploy your project here once the build is ready."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={tileMetaRow}>
|
||||
<StatusBlock
|
||||
color={isLive ? "#2e7d32" : isBuilding ? "#3d5afe" : "#9a7b3a"}
|
||||
label={isLive ? "Live" : isBuilding ? "Building" : statusFriendly(status)}
|
||||
/>
|
||||
<Metric label="App" value={app.name} />
|
||||
</div>
|
||||
{app.url ? (
|
||||
<div style={liveUrlRow}>
|
||||
<span style={liveUrlLabel}>Live URL</span>
|
||||
<span style={liveUrlValue}>{shortUrl(app.url)}</span>
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={liveUrlOpen}
|
||||
aria-label="Open live site"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div style={liveUrlRow}>
|
||||
<span style={liveUrlLabel}>Status</span>
|
||||
<span style={liveUrlValue}>{statusFriendly(status)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Small bits
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StagePill({ stage }: { stage: string }) {
|
||||
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||||
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a12" },
|
||||
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
};
|
||||
const s = map[stage] ?? map.discovery;
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||
color: s.color, background: s.bg, fontFamily: INK.fontSans,
|
||||
whiteSpace: "nowrap", flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBlock({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={metricLabel}>Status</span>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
||||
<span style={metricLabel}>{label}</span>
|
||||
<span style={{
|
||||
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TileLoader({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 8, padding: "32px 0", color: INK.muted, fontSize: 13,
|
||||
fontFamily: INK.fontSans,
|
||||
}}>
|
||||
<Loader2 className="animate-spin" size={14} /> {label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TileEmpty({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "28px 8px",
|
||||
textAlign: "center",
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
||||
fontFamily: INK.fontSans,
|
||||
}}>
|
||||
<span style={{ ...tileIconWrap, width: 38, height: 38 }}>{icon}</span>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: INK.ink }}>{title}</div>
|
||||
<div style={{ fontSize: 12.5, color: INK.muted, lineHeight: 1.55, maxWidth: 280 }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusFriendly(status: string): string {
|
||||
if (!status || status === "unknown") return "Unknown";
|
||||
return status.replace(/[:_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function ext(name: string): string {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot > 0 ? name.slice(dot + 1) : "file";
|
||||
}
|
||||
|
||||
function shortUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.host + (u.pathname === "/" ? "" : u.pathname);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function hoverEnter(e: React.MouseEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
el.style.borderColor = INK.borderHover;
|
||||
el.style.boxShadow = INK.shadowHover;
|
||||
}
|
||||
function hoverLeave(e: React.MouseEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
el.style.borderColor = INK.border;
|
||||
el.style.boxShadow = INK.shadow;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const pageWrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
background: INK.pageBg,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const pageInner: React.CSSProperties = {
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
padding: "44px 52px 64px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 28,
|
||||
};
|
||||
|
||||
const centeredFiller: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
height: "100%", padding: 64,
|
||||
};
|
||||
|
||||
const heroStyle: React.CSSProperties = {
|
||||
display: "flex", alignItems: "flex-start", justifyContent: "space-between",
|
||||
gap: 24,
|
||||
};
|
||||
|
||||
const eyebrow: React.CSSProperties = {
|
||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
fontFamily: INK.fontSans, marginBottom: 8,
|
||||
};
|
||||
|
||||
const heroTitle: React.CSSProperties = {
|
||||
fontFamily: INK.fontSerif,
|
||||
fontSize: "1.9rem", fontWeight: 400,
|
||||
color: INK.ink, letterSpacing: "-0.03em",
|
||||
lineHeight: 1.15, margin: 0,
|
||||
};
|
||||
|
||||
const heroDesc: React.CSSProperties = {
|
||||
fontSize: "0.88rem", color: INK.mid, marginTop: 10, maxWidth: 620,
|
||||
lineHeight: 1.6, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const continueRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16,
|
||||
background: INK.cardBg, border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10, padding: "14px 18px",
|
||||
textDecoration: "none", color: "inherit",
|
||||
boxShadow: INK.shadow,
|
||||
fontFamily: INK.fontSans,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
};
|
||||
|
||||
const continueDot: React.CSSProperties = {
|
||||
width: 7, height: 7, borderRadius: "50%",
|
||||
background: "#d4a04a", flexShrink: 0,
|
||||
};
|
||||
|
||||
const continueTitle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 600, color: INK.ink,
|
||||
};
|
||||
|
||||
const continueSub: React.CSSProperties = {
|
||||
fontSize: 12, color: INK.muted, marginTop: 2,
|
||||
};
|
||||
|
||||
const tileGrid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
|
||||
gap: 14,
|
||||
};
|
||||
|
||||
const tileLink: React.CSSProperties = {
|
||||
textDecoration: "none", color: "inherit",
|
||||
};
|
||||
|
||||
const tileCard: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10,
|
||||
padding: 22,
|
||||
display: "flex", flexDirection: "column", gap: 18,
|
||||
minHeight: 280,
|
||||
boxShadow: INK.shadow,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileHeader: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
};
|
||||
|
||||
const tileIconWrap: React.CSSProperties = {
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: INK.iconWrapBg, color: INK.ink,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const tileTitle: React.CSSProperties = {
|
||||
fontFamily: INK.fontSerif,
|
||||
fontSize: "1.05rem", fontWeight: 400,
|
||||
color: INK.ink, letterSpacing: "-0.02em",
|
||||
margin: 0, lineHeight: 1.2,
|
||||
};
|
||||
|
||||
const tileSubtitle: React.CSSProperties = {
|
||||
fontSize: 12, color: INK.muted, marginTop: 3,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileBody: React.CSSProperties = {
|
||||
display: "flex", flexDirection: "column", gap: 14, flex: 1, minHeight: 0,
|
||||
};
|
||||
|
||||
const tileMetaRow: React.CSSProperties = {
|
||||
display: "flex", gap: 28,
|
||||
};
|
||||
|
||||
const metricLabel: React.CSSProperties = {
|
||||
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const fileList: React.CSSProperties = {
|
||||
listStyle: "none", padding: 0, margin: 0,
|
||||
display: "flex", flexDirection: "column",
|
||||
border: `1px solid ${INK.border}`, borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
background: "#fdfcfa",
|
||||
};
|
||||
|
||||
const fileRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "8px 12px",
|
||||
borderTop: `1px solid ${INK.border}`,
|
||||
fontSize: 12.5, color: INK.ink,
|
||||
};
|
||||
|
||||
const fileIconWrap: React.CSSProperties = {
|
||||
color: INK.stone, display: "flex", alignItems: "center",
|
||||
};
|
||||
|
||||
const fileName: React.CSSProperties = {
|
||||
flex: 1, minWidth: 0, overflow: "hidden",
|
||||
textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
fontFamily: INK.fontMono, fontSize: 12,
|
||||
};
|
||||
|
||||
const fileType: React.CSSProperties = {
|
||||
fontSize: 10, color: INK.stone, fontWeight: 500,
|
||||
textTransform: "uppercase", letterSpacing: "0.08em",
|
||||
flexShrink: 0, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileMore: React.CSSProperties = {
|
||||
fontSize: 11.5, color: INK.muted, paddingLeft: 4,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const liveUrlRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "10px 12px",
|
||||
background: "#fdfcfa",
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const liveUrlLabel: React.CSSProperties = {
|
||||
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
flexShrink: 0, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const liveUrlValue: React.CSSProperties = {
|
||||
flex: 1, minWidth: 0,
|
||||
fontSize: 12, color: INK.ink, fontWeight: 500,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
fontFamily: INK.fontMono,
|
||||
};
|
||||
|
||||
const liveUrlOpen: React.CSSProperties = {
|
||||
width: 24, height: 24, borderRadius: 6,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: INK.ink, background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`, flexShrink: 0,
|
||||
textDecoration: "none",
|
||||
};
|
||||
Reference in New Issue
Block a user