From f30c2f790c413e62ffa2f5721862140b50e710d7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 23 Feb 2026 04:39:19 +0000 Subject: [PATCH] feat(web): add file manager page with list/grid views (#481) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../src/app/(authenticated)/files/page.tsx | 1436 +++++++++++++++++ 1 file changed, 1436 insertions(+) create mode 100644 apps/web/src/app/(authenticated)/files/page.tsx diff --git a/apps/web/src/app/(authenticated)/files/page.tsx b/apps/web/src/app/(authenticated)/files/page.tsx new file mode 100644 index 0000000..e563e1a --- /dev/null +++ b/apps/web/src/app/(authenticated)/files/page.tsx @@ -0,0 +1,1436 @@ +"use client"; + +import type { ReactElement, SyntheticEvent } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { + Plus, + Trash2, + List, + LayoutGrid, + Search, + ArrowUpDown, + ChevronUp, + ChevronDown, +} from "lucide-react"; +import type { KnowledgeEntryWithTags } from "@mosaic/shared"; +import { EntryStatus, Visibility } from "@mosaic/shared"; + +import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { fetchEntries, createEntry, deleteEntry } from "@/lib/api/knowledge"; +import type { EntriesResponse, CreateEntryData, EntryFilters } from "@/lib/api/knowledge"; + +/* --------------------------------------------------------------------------- + Helpers + --------------------------------------------------------------------------- */ + +type SortField = "title" | "updatedAt" | "createdAt"; +type SortOrder = "asc" | "desc"; +type ViewMode = "list" | "grid"; + +interface StatusStyle { + label: string; + bg: string; + color: string; +} + +function getStatusStyle(status: EntryStatus): StatusStyle { + switch (status) { + case EntryStatus.PUBLISHED: + return { label: "Published", bg: "rgba(20,184,166,0.15)", color: "var(--ms-teal-400)" }; + case EntryStatus.DRAFT: + return { label: "Draft", bg: "rgba(245,158,11,0.15)", color: "var(--ms-amber-400)" }; + case EntryStatus.ARCHIVED: + return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +interface VisibilityStyle { + label: string; + bg: string; + color: string; +} + +function getVisibilityStyle(visibility: Visibility): VisibilityStyle { + switch (visibility) { + case Visibility.PUBLIC: + return { label: "Public", bg: "rgba(47,128,255,0.15)", color: "var(--ms-blue-400)" }; + case Visibility.WORKSPACE: + return { label: "Workspace", bg: "rgba(139,92,246,0.15)", color: "var(--ms-purple-400)" }; + case Visibility.PRIVATE: + return { label: "Private", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: String(visibility), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function formatTimestamp(date: Date | string): string { + try { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return String(date); + } +} + +/* --------------------------------------------------------------------------- + Status / Visibility Badges + --------------------------------------------------------------------------- */ + +function StatusBadge({ status }: { status: EntryStatus }): ReactElement { + const s = getStatusStyle(status); + return ( + + {s.label} + + ); +} + +function VisibilityBadge({ visibility }: { visibility: Visibility }): ReactElement { + const v = getVisibilityStyle(visibility); + return ( + + {v.label} + + ); +} + +/* --------------------------------------------------------------------------- + Tag Pill + --------------------------------------------------------------------------- */ + +function TagPill({ name }: { name: string }): ReactElement { + return ( + + {name} + + ); +} + +/* --------------------------------------------------------------------------- + Sort Header (for list view) + --------------------------------------------------------------------------- */ + +interface SortHeaderProps { + label: string; + field: SortField; + currentSort: SortField; + currentOrder: SortOrder; + onSort: (field: SortField) => void; + style?: React.CSSProperties; +} + +function SortHeader({ + label, + field, + currentSort, + currentOrder, + onSort, + style, +}: SortHeaderProps): ReactElement { + const isActive = currentSort === field; + return ( + + ); +} + +/* --------------------------------------------------------------------------- + Grid Card + --------------------------------------------------------------------------- */ + +interface GridCardProps { + entry: KnowledgeEntryWithTags; + onDelete: (slug: string) => void; + onClick: (slug: string) => void; +} + +function GridCard({ entry, onDelete, onClick }: GridCardProps): ReactElement { + const [hovered, setHovered] = useState(false); + + return ( +
{ + onClick(entry.slug); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(entry.slug); + } + }} + onMouseEnter={() => { + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + }} + style={{ + background: "var(--surface)", + border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`, + borderRadius: "var(--r-lg)", + padding: 20, + cursor: "pointer", + transition: "border-color 0.2s var(--ease)", + display: "flex", + flexDirection: "column", + gap: 12, + position: "relative", + }} + > + {/* Header: title + delete */} +
+
+

+ {entry.title} +

+
+ +
+ + {/* Status badge */} +
+ + +
+ + {/* Tags (first 2) */} + {entry.tags.length > 0 && ( +
+ {entry.tags.slice(0, 2).map((tag) => ( + + ))} + {entry.tags.length > 2 && ( + + +{String(entry.tags.length - 2)} + + )} +
+ )} + + {/* Description */} + {entry.summary ? ( +

+ {entry.summary} +

+ ) : null} + + {/* Footer: updated date */} +
+ + {formatTimestamp(entry.updatedAt)} + +
+
+ ); +} + +/* --------------------------------------------------------------------------- + Submit Button (extracted to avoid inline ternary line-length issues) + --------------------------------------------------------------------------- */ + +interface SubmitButtonProps { + isSubmitting: boolean; + canSubmit: boolean; +} + +function SubmitButton({ isSubmitting, canSubmit }: SubmitButtonProps): ReactElement { + const enabled = canSubmit && !isSubmitting; + return ( + + ); +} + +/* --------------------------------------------------------------------------- + Create Entry Dialog + --------------------------------------------------------------------------- */ + +interface CreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: CreateEntryData) => Promise; + isSubmitting: boolean; +} + +function CreateEntryDialog({ + open, + onOpenChange, + onSubmit, + isSubmitting, +}: CreateDialogProps): ReactElement { + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [summary, setSummary] = useState(""); + const [status, setStatus] = useState(EntryStatus.DRAFT); + const [visibility, setVisibility] = useState(Visibility.PRIVATE); + const [formError, setFormError] = useState(null); + + function resetForm(): void { + setTitle(""); + setContent(""); + setSummary(""); + setStatus(EntryStatus.DRAFT); + setVisibility(Visibility.PRIVATE); + setFormError(null); + } + + async function handleSubmit(e: SyntheticEvent): Promise { + e.preventDefault(); + setFormError(null); + + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + setFormError("Title is required."); + return; + } + + const trimmedContent = content.trim(); + if (!trimmedContent) { + setFormError("Content is required."); + return; + } + + try { + const payload: CreateEntryData = { + title: trimmedTitle, + content: trimmedContent, + status, + visibility, + }; + const trimmedSummary = summary.trim(); + if (trimmedSummary) { + payload.summary = trimmedSummary; + } + await onSubmit(payload); + resetForm(); + } catch (err: unknown) { + setFormError(err instanceof Error ? err.message : "Failed to create entry."); + } + } + + return ( + { + if (!isOpen) resetForm(); + onOpenChange(isOpen); + }} + > + +
+ + + New Entry + + + Create a new knowledge entry. + + + +
{ + void handleSubmit(e); + }} + style={{ marginTop: 16 }} + > + {/* Title */} +
+ + { + setTitle(e.target.value); + }} + placeholder="e.g. API Authentication Guide" + maxLength={255} + autoFocus + style={{ + width: "100%", + padding: "8px 12px", + background: "var(--bg)", + border: "1px solid var(--border)", + borderRadius: "var(--r)", + color: "var(--text)", + fontSize: "0.9rem", + outline: "none", + boxSizing: "border-box", + }} + /> +
+ + {/* Content */} +
+ +