import ProjectStudioShell from "@/pages/project-studio/components/shell";
import { StudioTabNav, StudioTabLink } from "@/components/ui/studio-tab-nav";
import Badge from "@/components/ui/badge";
import Button from "@/components/ui/button";
import Input from "@/components/ui/input";
import Card from "@/components/ui/card";
import CardContent from "@/components/ui/card-content";
import { initFilesBehavior } from "@/pages/project-studio/files/files-behavior";
export const page = {
html: { lang: "en" },
body: { className: "font-sans" },
navigation: "history",
};
export function getPage(input) {
return {
head: {
title: input?.seo?.title ?? "",
description: input?.seo?.description ?? "",
},
};
}
export default function Page(input) {
initFilesBehavior();
const activeTab = input?.active_tab ?? "storages";
const selectedStorage = input?.selected_storage ?? "default";
const base = `/projects/${input.owner}/${input.project}/files`;
const api = input?.api ?? {};
const storages = Array.isArray(input?.storages) ? input.storages : [];
const browser = input?.browser ?? { path: "", folders: [], files: [] };
const folders = Array.isArray(browser?.folders) ? browser.folders : [];
const files = Array.isArray(browser?.files) ? browser.files : [];
const currentPath: string = browser?.path ?? "";
// Build breadcrumb segments from current path
const crumbs: Array<{ label: string; path: string }> = [];
if (currentPath) {
const parts = currentPath.split("/");
let acc = "";
for (const p of parts) {
acc = acc ? `${acc}/${p}` : p;
crumbs.push({ label: p, path: acc });
}
}
return (
<ProjectStudioShell
projectHref={input.project_href}
projectLabel={input.title}
currentMenu="Files"
owner={input.owner}
project={input.project}
nav={input.nav}
>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<StudioTabNav>
{activeTab === "explorer" ? (
<StudioTabLink href={`${base}/${selectedStorage}`} active>Explorer</StudioTabLink>
) : (
<StudioTabLink href={base} active>Storages</StudioTabLink>
)}
</StudioTabNav>
<section className="flex-1 min-h-0 overflow-auto flex flex-col bg-bg">
{activeTab === "storages" ? (
<div className="project-content-wrap">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-[0.95rem] font-semibold text-body">Storages</h2>
<p className="text-[0.76rem] text-body-muted mt-1">
Project artifact storage. Every project starts with a default ZebFS namespace.
</p>
</div>
</div>
<div className="overflow-hidden rounded-md border border-border bg-surface">
<table className="w-full border-collapse text-[0.78rem]">
<thead className="bg-surface-2 text-body-muted">
<tr>
<th className="text-left font-medium px-3 py-2 border-b border-border">Name</th>
<th className="text-left font-medium px-3 py-2 border-b border-border">Backend</th>
<th className="text-left font-medium px-3 py-2 border-b border-border">Namespace</th>
<th className="text-left font-medium px-3 py-2 border-b border-border">Tags</th>
<th className="text-right font-medium px-3 py-2 border-b border-border">Action</th>
</tr>
</thead>
<tbody>
{storages.map((storage) => (
<StorageRow key={storage.name} storage={storage} />
))}
</tbody>
</table>
</div>
</div>
</div>
) : null}
{activeTab === "explorer" ? (
<div
className="flex flex-col flex-1 min-h-0"
data-files-browser="true"
data-owner={input.owner ?? ""}
data-project={input.project ?? ""}
data-api-list={api.list ?? ""}
data-api-mkdir={api.mkdir ?? ""}
data-api-upload={api.upload ?? ""}
data-api-rm={api.rm ?? ""}
data-current-path={currentPath}
>
{/* Toolbar */}
<div className="flex items-center gap-3 px-3.5 py-2.5 border-b border-border bg-surface">
{/* Breadcrumbs */}
<div className="flex flex-1 min-w-0 flex-wrap items-center gap-1 text-[0.78rem]">
<a
href={base}
className="text-body-soft hover:text-body transition-colors"
>
storages
</a>
<span className="text-border">/</span>
<span className="text-body font-medium">{selectedStorage}</span>
<span className="text-border">/</span>
<button
type="button"
className="text-body-soft hover:text-body transition-colors bg-transparent border-0 p-0 cursor-pointer"
data-crumb-path=""
>
files/
</button>
{crumbs.map((crumb) => (
<span key={crumb.path} className="flex items-center gap-1">
<span className="text-border">/</span>
<button
type="button"
className="text-body-soft hover:text-body transition-colors bg-transparent border-0 p-0 cursor-pointer"
data-crumb-path={crumb.path}
>
{crumb.label}
</button>
</span>
))}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<input type="file" hidden data-file-upload-input="true" />
<Button variant="outline" size="xs" data-file-upload-trigger="true">Upload</Button>
<Button variant="outline" size="xs" data-new-folder-toggle="true">+ Folder</Button>
</div>
</div>
{/* New folder inline form */}
<div
hidden
data-new-folder-form="true"
className="flex items-center gap-2 px-3 py-2 border-b border-border-soft flex-wrap"
>
<Input
name="folder_name"
type="text"
placeholder="folder-name"
className="pipeline-registry-inline-input"
data-new-folder-input="true"
/>
<Button size="xs" data-new-folder-submit="true">Create Folder</Button>
<Button variant="outline" size="xs" data-new-folder-cancel="true">Cancel</Button>
</div>
{/* File + folder list */}
<div className="flex flex-col py-2 px-3 gap-0.5">
{folders.length === 0 && files.length === 0 ? (
<p className="px-2 py-6 text-[0.78rem] text-body-muted">
{currentPath
? "Empty folder"
: <>No objects yet. Upload here or via a pipeline using <code className="font-mono text-[0.75rem]">n.fs.save</code>.</>
}
</p>
) : null}
{folders.map((folder) => (
<FolderRow key={folder.path} folder={folder} />
))}
{files.map((file) => (
<FileRow key={file.path} file={file} />
))}
</div>
</div>
) : null}
</section>
</div>
</ProjectStudioShell>
);
}
// ── Sub-components ────────────────────────────────────────────────────────────
function StorageRow({ storage }) {
const tags = Array.isArray(storage.tags) ? storage.tags : [];
return (
<tr className="border-b border-border-soft last:border-b-0">
<td className="px-3 py-2.5 text-body font-medium">{storage.name}</td>
<td className="px-3 py-2.5 text-body-soft">{storage.backend}</td>
<td className="px-3 py-2.5 text-body-soft font-mono text-[0.74rem]">{storage.namespace}</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-[0.65rem]">{tag}</Badge>
))}
</div>
</td>
<td className="px-3 py-2.5 text-right">
<a
href={storage.open_href}
className="inline-flex items-center justify-center min-h-7 px-2.5 rounded border border-border bg-surface-2 text-body hover:border-accent hover:text-accent transition-colors"
>
Open
</a>
</td>
</tr>
);
}
function FolderRow({ folder }) {
return (
<div
className="group flex items-center gap-2 min-h-[2.1rem] px-2 py-1.5 rounded-md border border-dashed border-border-soft text-body-soft text-[0.8rem] cursor-pointer hover:bg-surface-2 hover:text-body hover:border-border transition-colors"
data-folder-path={folder.path}
>
<FolderIcon />
<span className="flex-1 min-w-0 truncate font-medium text-[0.78rem] text-body">{folder.name}</span>
{folder.protected
? <Badge variant="outline" className="text-[0.65rem] shrink-0">protected</Badge>
: (
<button
type="button"
className="hidden group-hover:flex items-center justify-center w-6 h-6 rounded shrink-0 text-body-muted hover:text-red-400 hover:bg-red-400/10 transition-colors"
data-delete-btn
data-delete-path={folder.path}
title="Delete folder"
>
<TrashIcon />
</button>
)
}
</div>
);
}
function FileRow({ file }) {
const ext = (file.name?.split(".").pop() ?? "").toLowerCase();
const isImage = ["jpg","jpeg","png","gif","webp","svg","avif","bmp"].includes(ext);
return (
<div className="group flex items-center gap-2 min-h-[2.1rem] px-2 py-1.5 rounded-md border border-border-soft bg-surface-2 hover:border-border transition-colors">
{isImage ? <ImageFileIcon /> : <GenericFileIcon />}
<a
className="flex-1 min-w-0 truncate font-medium text-[0.78rem] text-body hover:text-accent hover:underline"
href={file.url}
target="_blank"
rel="noopener"
>
{file.name}
</a>
<span className="text-[0.7rem] text-body-muted whitespace-nowrap shrink-0">
{formatBytes(file.size)}
{file.modified ? ` · ${new Date(file.modified * 1000).toLocaleDateString()}` : ""}
</span>
<button
type="button"
className="hidden group-hover:flex items-center justify-center w-6 h-6 rounded shrink-0 text-body-muted hover:text-red-400 hover:bg-red-400/10 transition-colors"
data-delete-btn
data-delete-path={file.path}
title="Delete file"
>
<TrashIcon />
</button>
</div>
);
}
function FolderIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" fillOpacity="0.15" className="w-4 h-4 shrink-0 text-amber-400" aria-hidden="true">
<path d="M4 6h6l2 2h8v10H4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
}
function ImageFileIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="w-4 h-4 shrink-0 text-sky-400" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6" />
<circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M21 15l-5-5L5 21" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function GenericFileIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="w-4 h-4 shrink-0 text-body-soft" aria-hidden="true">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M14 2v6h6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
}
function TrashIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="w-3.5 h-3.5" aria-hidden="true">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function S3Panel() {
return (
<div className="project-settings-panel">
<div className="project-settings-panel-head">
<p className="project-card-label">S3 / Object Storage</p>
<Badge variant="outline">Coming soon</Badge>
</div>
<div className="project-settings-panel-body flex flex-col gap-6 pt-2">
<Card className="opacity-60">
<CardContent className="flex items-start gap-4 pt-5">
<div className="mt-0.5 rounded bg-[color-mix(in_srgb,var(--color-accent)_12%,transparent)] p-2 text-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 8V16a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
<path d="M3 8l9-5 9 5"/>
<path d="M12 3v18"/>
</svg>
</div>
<div className="flex-1">
<p className="text-[0.88rem] font-semibold text-body">Amazon S3 / S3-Compatible</p>
<p className="mt-0.5 text-[0.78rem] text-body-soft">
Connect an S3 bucket (AWS S3, Cloudflare R2, MinIO, Backblaze B2) as the primary
file backend. Files stored in the bucket and served through the Zebflow FS contract.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{["AWS S3", "Cloudflare R2", "MinIO", "Backblaze B2", "Tigris"].map((label) => (
<Badge key={label} variant="secondary" className="text-[0.72rem]">{label}</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}