Files
stack/apps/web/src/app/(authenticated)/files/page.tsx
Jason Woltje f30c2f790c
Some checks failed
ci/woodpecker/push/web Pipeline failed
feat(web): add file manager page with list/grid views (#481)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:39:19 +00:00

1437 lines
40 KiB
TypeScript

"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 (
<span
style={{
display: "inline-block",
padding: "2px 10px",
borderRadius: "var(--r)",
background: s.bg,
color: s.color,
fontSize: "0.75rem",
fontWeight: 500,
whiteSpace: "nowrap",
}}
>
{s.label}
</span>
);
}
function VisibilityBadge({ visibility }: { visibility: Visibility }): ReactElement {
const v = getVisibilityStyle(visibility);
return (
<span
style={{
display: "inline-block",
padding: "2px 10px",
borderRadius: "var(--r)",
background: v.bg,
color: v.color,
fontSize: "0.75rem",
fontWeight: 500,
whiteSpace: "nowrap",
}}
>
{v.label}
</span>
);
}
/* ---------------------------------------------------------------------------
Tag Pill
--------------------------------------------------------------------------- */
function TagPill({ name }: { name: string }): ReactElement {
return (
<span
style={{
display: "inline-block",
padding: "1px 8px",
borderRadius: "var(--r)",
background: "var(--surface-2)",
color: "var(--muted)",
fontSize: "0.7rem",
fontWeight: 500,
whiteSpace: "nowrap",
}}
>
{name}
</span>
);
}
/* ---------------------------------------------------------------------------
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 (
<button
type="button"
onClick={() => {
onSort(field);
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
background: "transparent",
border: "none",
cursor: "pointer",
padding: 0,
color: isActive ? "var(--text)" : "var(--muted)",
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
...style,
}}
>
{label}
{isActive ? (
currentOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
) : (
<ArrowUpDown size={12} style={{ opacity: 0.5 }} />
)}
</button>
);
}
/* ---------------------------------------------------------------------------
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 (
<div
role="button"
tabIndex={0}
onClick={() => {
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 */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<h3
style={{
fontWeight: 600,
color: "var(--text)",
fontSize: "1rem",
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entry.title}
</h3>
</div>
<button
aria-label={`Delete ${entry.title}`}
onClick={(e) => {
e.stopPropagation();
onDelete(entry.slug);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 4,
borderRadius: "var(--r-sm)",
color: "var(--muted)",
transition: "color 0.15s, background 0.15s",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
marginLeft: 8,
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--danger)";
e.currentTarget.style.background = "rgba(229,72,77,0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--muted)";
e.currentTarget.style.background = "transparent";
}}
>
<Trash2 size={16} />
</button>
</div>
{/* Status badge */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusBadge status={entry.status} />
<VisibilityBadge visibility={entry.visibility} />
</div>
{/* Tags (first 2) */}
{entry.tags.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{entry.tags.slice(0, 2).map((tag) => (
<TagPill key={tag.id} name={tag.name} />
))}
{entry.tags.length > 2 && (
<span style={{ fontSize: "0.7rem", color: "var(--muted)" }}>
+{String(entry.tags.length - 2)}
</span>
)}
</div>
)}
{/* Description */}
{entry.summary ? (
<p
style={{
color: "var(--muted)",
fontSize: "0.85rem",
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
lineHeight: 1.5,
}}
>
{entry.summary}
</p>
) : null}
{/* Footer: updated date */}
<div style={{ marginTop: "auto" }}>
<span
style={{
fontSize: "0.75rem",
color: "var(--muted)",
fontFamily: "var(--mono)",
}}
>
{formatTimestamp(entry.updatedAt)}
</span>
</div>
</div>
);
}
/* ---------------------------------------------------------------------------
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 (
<button
type="submit"
disabled={!enabled}
style={{
padding: "8px 16px",
background: "var(--primary)",
border: "none",
borderRadius: "var(--r)",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: enabled ? "pointer" : "not-allowed",
opacity: enabled ? 1 : 0.6,
}}
>
{isSubmitting ? "Creating..." : "Create Entry"}
</button>
);
}
/* ---------------------------------------------------------------------------
Create Entry Dialog
--------------------------------------------------------------------------- */
interface CreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateEntryData) => Promise<void>;
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>(EntryStatus.DRAFT);
const [visibility, setVisibility] = useState<Visibility>(Visibility.PRIVATE);
const [formError, setFormError] = useState<string | null>(null);
function resetForm(): void {
setTitle("");
setContent("");
setSummary("");
setStatus(EntryStatus.DRAFT);
setVisibility(Visibility.PRIVATE);
setFormError(null);
}
async function handleSubmit(e: SyntheticEvent): Promise<void> {
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 (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) resetForm();
onOpenChange(isOpen);
}}
>
<DialogContent>
<div
style={{
background: "var(--surface)",
borderRadius: "var(--r-lg)",
border: "1px solid var(--border)",
padding: 24,
position: "relative",
}}
>
<DialogHeader>
<DialogTitle>
<span style={{ color: "var(--text)" }}>New Entry</span>
</DialogTitle>
<DialogDescription>
<span style={{ color: "var(--muted)" }}>Create a new knowledge entry.</span>
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
void handleSubmit(e);
}}
style={{ marginTop: 16 }}
>
{/* Title */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="entry-title"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2)",
}}
>
Title <span style={{ color: "var(--danger)" }}>*</span>
</label>
<input
id="entry-title"
type="text"
value={title}
onChange={(e) => {
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",
}}
/>
</div>
{/* Content */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="entry-content"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2)",
}}
>
Content <span style={{ color: "var(--danger)" }}>*</span>
</label>
<textarea
id="entry-content"
value={content}
onChange={(e) => {
setContent(e.target.value);
}}
placeholder="Write your knowledge entry content..."
rows={4}
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",
resize: "vertical",
fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
</div>
{/* Summary */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="entry-summary"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2)",
}}
>
Summary
</label>
<input
id="entry-summary"
type="text"
value={summary}
onChange={(e) => {
setSummary(e.target.value);
}}
placeholder="Brief summary (optional)"
maxLength={500}
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",
}}
/>
</div>
{/* Status + Visibility row */}
<div style={{ display: "flex", gap: 16, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label
htmlFor="entry-status"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2)",
}}
>
Status
</label>
<select
id="entry-status"
value={status}
onChange={(e) => {
setStatus(e.target.value as EntryStatus);
}}
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",
}}
>
<option value={EntryStatus.DRAFT}>Draft</option>
<option value={EntryStatus.PUBLISHED}>Published</option>
<option value={EntryStatus.ARCHIVED}>Archived</option>
</select>
</div>
<div style={{ flex: 1 }}>
<label
htmlFor="entry-visibility"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2)",
}}
>
Visibility
</label>
<select
id="entry-visibility"
value={visibility}
onChange={(e) => {
setVisibility(e.target.value as Visibility);
}}
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",
}}
>
<option value={Visibility.PRIVATE}>Private</option>
<option value={Visibility.WORKSPACE}>Workspace</option>
<option value={Visibility.PUBLIC}>Public</option>
</select>
</div>
</div>
{/* Form error */}
{formError !== null && (
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
{formError}
</p>
)}
<DialogFooter>
<button
type="button"
onClick={() => {
onOpenChange(false);
}}
disabled={isSubmitting}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
color: "var(--text-2)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Cancel
</button>
<SubmitButton
isSubmitting={isSubmitting}
canSubmit={!!title.trim() && !!content.trim()}
/>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
);
}
/* ---------------------------------------------------------------------------
Delete Confirmation Dialog
--------------------------------------------------------------------------- */
interface DeleteDialogProps {
open: boolean;
entryTitle: string;
onConfirm: () => void;
onCancel: () => void;
isDeleting: boolean;
}
function DeleteConfirmDialog({
open,
entryTitle,
onConfirm,
onCancel,
isDeleting,
}: DeleteDialogProps): ReactElement {
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) onCancel();
}}
>
<DialogContent>
<div
style={{
background: "var(--surface)",
borderRadius: "var(--r-lg)",
border: "1px solid var(--border)",
padding: 24,
}}
>
<DialogHeader>
<DialogTitle>
<span style={{ color: "var(--text)" }}>Delete Entry</span>
</DialogTitle>
<DialogDescription>
<span style={{ color: "var(--muted)" }}>
{"This will permanently delete "}
<strong style={{ color: "var(--text)" }}>{entryTitle}</strong>
{". This action cannot be undone."}
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
color: "var(--text-2)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
style={{
padding: "8px 16px",
background: "var(--danger)",
border: "none",
borderRadius: "var(--r)",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: isDeleting ? "not-allowed" : "pointer",
opacity: isDeleting ? 0.6 : 1,
}}
>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
}
/* ---------------------------------------------------------------------------
File Manager Page
--------------------------------------------------------------------------- */
export default function FileManagerPage(): ReactElement {
const router = useRouter();
// Data state
const [entries, setEntries] = useState<KnowledgeEntryWithTags[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// View state
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<SortField>("updatedAt");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
// Debounced search
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [debouncedSearch, setDebouncedSearch] = useState("");
// Create dialog state
const [createOpen, setCreateOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
// Delete dialog state
const [deleteTarget, setDeleteTarget] = useState<KnowledgeEntryWithTags | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Debounce search input
useEffect(() => {
if (searchTimerRef.current !== null) {
clearTimeout(searchTimerRef.current);
}
searchTimerRef.current = setTimeout(() => {
setDebouncedSearch(searchQuery);
}, 300);
return (): void => {
if (searchTimerRef.current !== null) {
clearTimeout(searchTimerRef.current);
}
};
}, [searchQuery]);
// Load entries
const loadEntries = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const filters: EntryFilters = {
sortBy,
sortOrder,
limit: 100,
};
if (debouncedSearch.trim()) {
filters.search = debouncedSearch.trim();
}
const response: EntriesResponse = await fetchEntries(filters);
setEntries(response.data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load entries");
} finally {
setIsLoading(false);
}
}, [sortBy, sortOrder, debouncedSearch]);
useEffect(() => {
void loadEntries();
}, [loadEntries]);
// Sort toggle handler
function handleSortToggle(field: SortField): void {
if (sortBy === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(field);
setSortOrder(field === "title" ? "asc" : "desc");
}
}
// Navigation
function handleEntryClick(slug: string): void {
router.push(`/knowledge/${slug}`);
}
// Create
async function handleCreate(data: CreateEntryData): Promise<void> {
setIsCreating(true);
try {
await createEntry(data);
setCreateOpen(false);
void loadEntries();
} finally {
setIsCreating(false);
}
}
// Delete
function handleDeleteRequest(slug: string): void {
const target = entries.find((e) => e.slug === slug);
if (target) {
setDeleteTarget(target);
}
}
async function handleDeleteConfirm(): Promise<void> {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await deleteEntry(deleteTarget.slug);
setDeleteTarget(null);
void loadEntries();
} catch (err: unknown) {
console.error("[FileManager] Failed to delete entry:", err);
setError(err instanceof Error ? err.message : "Failed to delete entry.");
setDeleteTarget(null);
} finally {
setIsDeleting(false);
}
}
// Retry
function handleRetry(): void {
void loadEntries();
}
/* ---- Render ---- */
return (
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 1100 }}>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
marginBottom: 24,
flexWrap: "wrap",
gap: 16,
}}
>
<div>
<h1
style={{
fontSize: "1.875rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
File Manager
</h1>
<p
style={{
fontSize: "0.9rem",
color: "var(--muted)",
marginTop: 4,
}}
>
Browse and manage knowledge entries
</p>
</div>
<button
onClick={() => {
setCreateOpen(true);
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 16px",
background: "var(--primary)",
border: "none",
borderRadius: "var(--r)",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
<Plus size={16} />
New Entry
</button>
</div>
{/* Toolbar: Search + View toggle */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 20,
flexWrap: "wrap",
}}
>
{/* Search */}
<div
style={{
flex: 1,
minWidth: 200,
position: "relative",
}}
>
<Search
size={16}
style={{
position: "absolute",
left: 12,
top: "50%",
transform: "translateY(-50%)",
color: "var(--muted)",
pointerEvents: "none",
}}
/>
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
}}
placeholder="Search entries..."
style={{
width: "100%",
padding: "8px 12px 8px 36px",
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
color: "var(--text)",
fontSize: "0.9rem",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* View toggle */}
<div
style={{
display: "flex",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
overflow: "hidden",
}}
>
<button
type="button"
aria-label="List view"
onClick={() => {
setViewMode("list");
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "6px 10px",
background: viewMode === "list" ? "var(--surface-2)" : "var(--surface)",
border: "none",
cursor: "pointer",
color: viewMode === "list" ? "var(--text)" : "var(--muted)",
transition: "background 0.15s, color 0.15s",
}}
>
<List size={18} />
</button>
<button
type="button"
aria-label="Grid view"
onClick={() => {
setViewMode("grid");
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "6px 10px",
background: viewMode === "grid" ? "var(--surface-2)" : "var(--surface)",
border: "none",
borderLeft: "1px solid var(--border)",
cursor: "pointer",
color: viewMode === "grid" ? "var(--text)" : "var(--muted)",
transition: "background 0.15s, color 0.15s",
}}
>
<LayoutGrid size={18} />
</button>
</div>
</div>
{/* Loading state */}
{isLoading && entries.length === 0 ? (
<div className="flex justify-center py-16">
<MosaicSpinner label="Loading entries..." />
</div>
) : error !== null ? (
/* Error state */
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 32,
textAlign: "center",
}}
>
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
<button
type="button"
onClick={handleRetry}
style={{
padding: "8px 16px",
background: "var(--danger)",
border: "none",
borderRadius: "var(--r)",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
) : entries.length === 0 ? (
/* Empty state */
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 48,
textAlign: "center",
}}
>
<p style={{ color: "var(--muted)", margin: "0 0 16px", fontSize: "0.9rem" }}>
{debouncedSearch
? "No entries match your search."
: "No files found. Create your first entry to get started."}
</p>
{!debouncedSearch && (
<button
type="button"
onClick={() => {
setCreateOpen(true);
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 16px",
background: "var(--primary)",
border: "none",
borderRadius: "var(--r)",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
<Plus size={16} />
Create Entry
</button>
)}
</div>
) : viewMode === "list" ? (
/* List View */
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
overflow: "hidden",
}}
>
{/* Table header */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 110px 110px 160px 110px 40px",
gap: 0,
padding: "10px 16px",
background: "var(--bg-mid)",
borderBottom: "1px solid var(--border)",
alignItems: "center",
}}
>
<SortHeader
label="Name"
field="title"
currentSort={sortBy}
currentOrder={sortOrder}
onSort={handleSortToggle}
/>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--muted)",
}}
>
Status
</span>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--muted)",
}}
>
Visibility
</span>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--muted)",
}}
>
Tags
</span>
<SortHeader
label="Updated"
field="updatedAt"
currentSort={sortBy}
currentOrder={sortOrder}
onSort={handleSortToggle}
/>
{/* Actions column header - empty */}
<span />
</div>
{/* Table rows */}
{entries.map((entry) => (
<ListRow
key={entry.id}
entry={entry}
onDelete={handleDeleteRequest}
onClick={handleEntryClick}
/>
))}
</div>
) : (
/* Grid View */
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: 16,
}}
>
{entries.map((entry) => (
<GridCard
key={entry.id}
entry={entry}
onDelete={handleDeleteRequest}
onClick={handleEntryClick}
/>
))}
</div>
)}
{/* Create Dialog */}
<CreateEntryDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={handleCreate}
isSubmitting={isCreating}
/>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={deleteTarget !== null}
entryTitle={deleteTarget?.title ?? ""}
onConfirm={() => {
void handleDeleteConfirm();
}}
onCancel={() => {
setDeleteTarget(null);
}}
isDeleting={isDeleting}
/>
</main>
);
}
/* ---------------------------------------------------------------------------
List Row (extracted for hover state)
--------------------------------------------------------------------------- */
interface ListRowProps {
entry: KnowledgeEntryWithTags;
onDelete: (slug: string) => void;
onClick: (slug: string) => void;
}
function ListRow({ entry, onDelete, onClick }: ListRowProps): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<div
role="button"
tabIndex={0}
onClick={() => {
onClick(entry.slug);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick(entry.slug);
}
}}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
style={{
display: "grid",
gridTemplateColumns: "1fr 110px 110px 160px 110px 40px",
gap: 0,
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
cursor: "pointer",
background: hovered ? "var(--surface-2)" : "var(--surface)",
transition: "background 0.15s",
alignItems: "center",
}}
>
{/* Name */}
<span
style={{
fontWeight: 500,
color: "var(--text)",
fontSize: "0.9rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
paddingRight: 12,
}}
>
{entry.title}
</span>
{/* Status */}
<StatusBadge status={entry.status} />
{/* Visibility */}
<VisibilityBadge visibility={entry.visibility} />
{/* Tags */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, overflow: "hidden" }}>
{entry.tags.slice(0, 2).map((tag) => (
<TagPill key={tag.id} name={tag.name} />
))}
{entry.tags.length > 2 && (
<span style={{ fontSize: "0.7rem", color: "var(--muted)" }}>
+{String(entry.tags.length - 2)}
</span>
)}
</div>
{/* Updated */}
<span
style={{
fontSize: "0.8rem",
color: "var(--muted)",
fontFamily: "var(--mono)",
}}
>
{formatTimestamp(entry.updatedAt)}
</span>
{/* Delete action */}
<button
aria-label={`Delete ${entry.title}`}
onClick={(e) => {
e.stopPropagation();
onDelete(entry.slug);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 4,
borderRadius: "var(--r-sm)",
color: "var(--muted)",
transition: "color 0.15s, background 0.15s",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--danger)";
e.currentTarget.style.background = "rgba(229,72,77,0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--muted)";
e.currentTarget.style.background = "transparent";
}}
>
<Trash2 size={15} />
</button>
</div>
);
}