Intelligence

Artifacts

Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.

Repository
.gitignoreDockerfilenext-env.d.tsnext.config.mjspackage-lock.jsonpackage.jsonpostcss.config.mjsREADME.mdtailwind.config.tstsconfig.jsontsconfig.tsbuildinfo
README.md
CONSTITUTION_COMPLIANCE_AUDIT_V1.mdREADME.md
repositories/aaf-holdings/hq01/components/files/folder-manager.tsx
13.3 KB
"use client";

import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
  FolderPlus,
  Upload,
  Trash2,
  Folder,
  FileText,
  Loader2,
  X,
  AlertTriangle,
  CornerLeftUp,
  CheckCircle2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

interface DirEntry {
  name: string;
  type: "file" | "dir";
  path: string;
  ext?: string;
  size?: number;
  files?: number;
  folders?: number;
}

interface ManagedDir {
  path: string;
  isRoot: boolean;
  parent: string | null;
  entries: DirEntry[];
}

type Confirm =
  | { kind: "folder-self"; path: string; label: string; files: number; folders: number }
  | { kind: "folder"; path: string; label: string; files: number; folders: number }
  | { kind: "file"; path: string; label: string };

function formatBytes(bytes?: number): string {
  if (bytes == null) return "";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

/**
 * Document file-manager for a managed folder: create sub-folders, upload
 * documents into this folder, and delete folders/documents. All actions hit the
 * sandboxed /api/files endpoints and then refresh the server-rendered view.
 */
export function FolderManager({
  folder,
  accept,
}: {
  folder: ManagedDir;
  accept: string;
}) {
  const router = useRouter();
  const fileRef = useRef<HTMLInputElement>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState<string | null>(null);
  const [newOpen, setNewOpen] = useState(false);
  const [newName, setNewName] = useState("");
  const [confirm, setConfirm] = useState<Confirm | null>(null);

  function reset() {
    setError(null);
    setSuccess(null);
  }

  async function call(url: string, init: RequestInit): Promise<any> {
    const res = await fetch(url, init);
    const data = await res.json().catch(() => ({}));
    if (!res.ok || data.ok === false) {
      throw new Error(data.error ?? `Request failed (HTTP ${res.status}).`);
    }
    return data;
  }

  async function createFolder(e: React.FormEvent) {
    e.preventDefault();
    if (!newName.trim()) return;
    setBusy(true);
    reset();
    try {
      await call("/api/files/folder", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ parent: folder.path, name: newName }),
      });
      setNewName("");
      setNewOpen(false);
      setSuccess("Folder created.");
      router.refresh();
    } catch (err) {
      setError(err instanceof Error ? err.message : "Could not create folder.");
    } finally {
      setBusy(false);
    }
  }

  async function uploadHere() {
    const file = fileRef.current?.files?.[0];
    if (!file) return;
    setBusy(true);
    reset();
    try {
      const body = new FormData();
      body.append("dir", folder.path);
      body.append("file", file);
      const data = await call("/api/files/upload", { method: "POST", body });
      if (fileRef.current) fileRef.current.value = "";
      setSuccess(`Uploaded ${data.result.name}.`);
      router.refresh();
    } catch (err) {
      setError(err instanceof Error ? err.message : "Upload failed.");
    } finally {
      setBusy(false);
    }
  }

  async function runConfirm() {
    if (!confirm) return;
    setBusy(true);
    reset();
    try {
      if (confirm.kind === "file") {
        await call("/api/files/document", {
          method: "DELETE",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ path: confirm.path }),
        });
        setSuccess(`Deleted ${confirm.label}.`);
        setConfirm(null);
        router.refresh();
      } else {
        await call("/api/files/folder", {
          method: "DELETE",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ path: confirm.path }),
        });
        const isSelf = confirm.kind === "folder-self";
        setConfirm(null);
        setSuccess(`Deleted ${confirm.label}.`);
        if (isSelf && folder.parent) {
          router.push(`/artifacts?dir=${encodeURIComponent(folder.parent)}`);
        } else {
          router.refresh();
        }
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : "Delete failed.");
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="rounded-lg border border-border bg-card">
      {/* Header */}
      <div className="flex items-center justify-between gap-3 border-b border-border bg-secondary/40 px-5 py-3">
        <div className="flex min-w-0 items-center gap-2">
          <Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
          <span className="truncate font-mono text-[12px] font-medium text-foreground">
            {folder.path}
          </span>
        </div>
        {!folder.isRoot && folder.parent && (
          <Link
            href={`/artifacts?dir=${encodeURIComponent(folder.parent)}`}
            className="inline-flex shrink-0 items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
          >
            <CornerLeftUp className="h-3.5 w-3.5" /> Up
          </Link>
        )}
      </div>

      {/* Toolbar */}
      <div className="flex flex-wrap items-center gap-2 border-b border-border px-5 py-3">
        <Button
          type="button"
          size="sm"
          variant="outline"
          onClick={() => {
            setNewOpen((v) => !v);
            reset();
          }}
          disabled={busy}
        >
          <FolderPlus className="h-3.5 w-3.5" /> New folder
        </Button>

        <Button
          type="button"
          size="sm"
          variant="outline"
          onClick={() => {
            reset();
            fileRef.current?.click();
          }}
          disabled={busy}
        >
          <Upload className="h-3.5 w-3.5" /> Upload here
        </Button>
        <input
          ref={fileRef}
          type="file"
          accept={accept}
          className="hidden"
          onChange={uploadHere}
        />

        {!folder.isRoot && (
          <Button
            type="button"
            size="sm"
            variant="outline"
            className="ml-auto text-destructive hover:bg-destructive/10"
            disabled={busy}
            onClick={() =>
              setConfirm({
                kind: "folder-self",
                path: folder.path,
                label: folder.path.split("/").pop() ?? folder.path,
                files: folder.entries.filter((e) => e.type === "file").length,
                folders: folder.entries.filter((e) => e.type === "dir").length,
              })
            }
          >
            <Trash2 className="h-3.5 w-3.5" /> Delete folder
          </Button>
        )}
      </div>

      {newOpen && (
        <form
          onSubmit={createFolder}
          className="flex items-center gap-2 border-b border-border bg-secondary/20 px-5 py-3"
        >
          <Input
            autoFocus
            value={newName}
            onChange={(e) => setNewName(e.target.value)}
            placeholder="New folder name"
            className="h-9 max-w-xs"
          />
          <Button type="submit" size="sm" disabled={busy || !newName.trim()}>
            {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Create"}
          </Button>
        </form>
      )}

      {(error || success) && (
        <div
          className={cn(
            "flex items-center gap-2 px-5 py-2.5 text-[13px]",
            error ? "bg-destructive/10 text-destructive" : "bg-emerald-50 text-emerald-700",
          )}
        >
          {error ? (
            <AlertTriangle className="h-4 w-4 shrink-0" />
          ) : (
            <CheckCircle2 className="h-4 w-4 shrink-0" />
          )}
          {error ?? success}
        </div>
      )}

      {/* Contents */}
      {folder.entries.length === 0 ? (
        <div className="px-5 py-12 text-center text-sm text-muted-foreground">
          This folder is empty. Use <span className="font-medium">New folder</span>{" "}
          or <span className="font-medium">Upload here</span> to add to it.
        </div>
      ) : (
        <ul className="divide-y divide-border">
          {folder.entries.map((entry) => (
            <li
              key={entry.path}
              className="group flex items-center gap-3 px-5 py-2.5 hover:bg-secondary/40"
            >
              {entry.type === "dir" ? (
                <Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
              ) : (
                <FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
              )}
              <Link
                href={`/artifacts?${entry.type === "dir" ? "dir" : "file"}=${encodeURIComponent(entry.path)}`}
                className="min-w-0 flex-1 truncate text-[14px] text-foreground hover:underline"
              >
                {entry.name}
              </Link>
              <span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
                {entry.type === "dir"
                  ? `${entry.files ?? 0} file${(entry.files ?? 0) === 1 ? "" : "s"}`
                  : formatBytes(entry.size)}
              </span>
              <button
                type="button"
                disabled={busy}
                onClick={() =>
                  setConfirm(
                    entry.type === "dir"
                      ? {
                          kind: "folder",
                          path: entry.path,
                          label: entry.name,
                          files: entry.files ?? 0,
                          folders: entry.folders ?? 0,
                        }
                      : { kind: "file", path: entry.path, label: entry.name },
                  )
                }
                className="shrink-0 rounded-md p-1.5 text-muted-foreground/50 opacity-0 transition-colors hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
                title={`Delete ${entry.name}`}
              >
                <Trash2 className="h-4 w-4" />
              </button>
            </li>
          ))}
        </ul>
      )}

      {confirm && (
        <ConfirmDialog
          confirm={confirm}
          busy={busy}
          onCancel={() => setConfirm(null)}
          onConfirm={runConfirm}
        />
      )}
    </div>
  );
}

function ConfirmDialog({
  confirm,
  busy,
  onCancel,
  onConfirm,
}: {
  confirm: Confirm;
  busy: boolean;
  onCancel: () => void;
  onConfirm: () => void;
}) {
  const isFolder = confirm.kind !== "file";
  const total = isFolder ? confirm.files + confirm.folders : 0;

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
      <div className="w-full max-w-sm rounded-lg border border-border bg-card p-5 shadow-lg">
        <div className="flex items-start gap-3">
          <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-destructive/10">
            <AlertTriangle className="h-4 w-4 text-destructive" />
          </div>
          <div className="min-w-0">
            <h3 className="text-sm font-semibold text-foreground">
              Delete {isFolder ? "folder" : "document"}?
            </h3>
            <p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
              <span className="font-medium text-foreground">{confirm.label}</span>{" "}
              {isFolder ? (
                total === 0 ? (
                  "is empty and will be permanently removed."
                ) : (
                  <>
                    and everything inside it —{" "}
                    <span className="font-medium text-foreground">
                      {confirm.files} file{confirm.files === 1 ? "" : "s"}
                    </span>
                    {confirm.folders > 0 && (
                      <>
                        {" "}and{" "}
                        <span className="font-medium text-foreground">
                          {confirm.folders} sub-folder{confirm.folders === 1 ? "" : "s"}
                        </span>
                      </>
                    )}{" "}
                    — will be permanently removed.
                  </>
                )
              ) : (
                "will be permanently removed."
              )}{" "}
              This cannot be undone.
            </p>
          </div>
          <button
            type="button"
            onClick={onCancel}
            className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-secondary"
          >
            <X className="h-4 w-4" />
          </button>
        </div>
        <div className="mt-5 flex justify-end gap-2">
          <Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={busy}>
            Cancel
          </Button>
          <Button
            type="button"
            size="sm"
            className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
            onClick={onConfirm}
            disabled={busy}
          >
            {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
            Delete
          </Button>
        </div>
      </div>
    </div>
  );
}

root · /srv/aaf