Files
stack/apps/web/src/app/(authenticated)/files/page.tsx
Jason Woltje ffe428a9e9
Some checks failed
ci/woodpecker/push/ci Pipeline failed
fix: add tag input to file manager create entry form
2026-03-01 15:25:44 -06:00

1669 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, KnowledgeTag } 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, fetchTags } 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);
// Tag state
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const tagInputRef = useRef<HTMLInputElement>(null);
// Load available tags when dialog opens
useEffect(() => {
if (open) {
fetchTags()
.then((tags) => {
setAvailableTags(tags);
})
.catch((err) => {
console.error("Failed to load tags:", err);
});
}
}, [open]);
function resetForm(): void {
setTitle("");
setContent("");
setSummary("");
setStatus(EntryStatus.DRAFT);
setVisibility(Visibility.PRIVATE);
setFormError(null);
setSelectedTags([]);
setTagInput("");
setShowSuggestions(false);
}
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,
tags: selectedTags,
};
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>
{/* Tags */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="entry-tags"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2)",
}}
>
Tags
</label>
<div
style={{
width: "100%",
minHeight: 38,
padding: "6px 8px",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
boxSizing: "border-box",
display: "flex",
flexWrap: "wrap",
gap: 4,
alignItems: "center",
position: "relative",
}}
>
{/* Selected tag chips */}
{selectedTags.map((tag) => (
<span
key={tag}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 8px",
background: "var(--surface-2)",
border: "1px solid var(--border)",
borderRadius: "var(--r-sm)",
fontSize: "0.75rem",
color: "var(--text)",
}}
>
{tag}
<button
type="button"
onClick={() => {
setSelectedTags((prev) => prev.filter((t) => t !== tag));
}}
style={{
background: "transparent",
border: "none",
padding: 0,
cursor: "pointer",
color: "var(--muted)",
display: "flex",
alignItems: "center",
lineHeight: 1,
}}
>
×
</button>
</span>
))}
{/* Tag text input */}
<input
ref={tagInputRef}
id="entry-tags"
type="text"
value={tagInput}
onChange={(e) => {
setTagInput(e.target.value);
setShowSuggestions(e.target.value.length > 0);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const trimmed = tagInput.trim();
if (trimmed && !selectedTags.includes(trimmed)) {
setSelectedTags((prev) => [...prev, trimmed]);
setTagInput("");
}
}
if (e.key === "Backspace" && tagInput === "" && selectedTags.length > 0) {
setSelectedTags((prev) => prev.slice(0, -1));
}
}}
onBlur={() => {
// Delay to allow click on suggestion
setTimeout(() => setShowSuggestions(false), 150);
}}
onFocus={() => {
if (tagInput.length > 0) setShowSuggestions(true);
}}
placeholder={selectedTags.length === 0 ? "Add tags..." : ""}
style={{
flex: 1,
minWidth: 80,
border: "none",
background: "transparent",
color: "var(--text)",
fontSize: "0.85rem",
outline: "none",
padding: "2px 0",
}}
/>
{/* Autocomplete suggestions */}
{showSuggestions && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
marginTop: 4,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
maxHeight: 150,
overflowY: "auto",
zIndex: 10,
}}
>
{availableTags
.filter(
(t) =>
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
!selectedTags.includes(t.name)
)
.slice(0, 5)
.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
if (!selectedTags.includes(tag.name)) {
setSelectedTags((prev) => [...prev, tag.name]);
}
setTagInput("");
setShowSuggestions(false);
tagInputRef.current?.focus();
}}
style={{
width: "100%",
padding: "8px 12px",
background: "transparent",
border: "none",
textAlign: "left",
cursor: "pointer",
color: "var(--text)",
fontSize: "0.85rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--surface-2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
{tag.name}
</button>
))}
{availableTags.filter(
(t) =>
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
!selectedTags.includes(t.name)
).length === 0 &&
tagInput.trim() &&
!selectedTags.includes(tagInput.trim()) && (
<button
type="button"
onClick={() => {
const trimmed = tagInput.trim();
if (trimmed && !selectedTags.includes(trimmed)) {
setSelectedTags((prev) => [...prev, trimmed]);
}
setTagInput("");
setShowSuggestions(false);
tagInputRef.current?.focus();
}}
style={{
width: "100%",
padding: "8px 12px",
background: "transparent",
border: "none",
textAlign: "left",
cursor: "pointer",
color: "var(--muted)",
fontSize: "0.85rem",
fontStyle: "italic",
}}
>
Create "{tagInput.trim()}"
</button>
)}
</div>
)}
</div>
</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>
);
}