"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 ( {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); // Tag state const [selectedTags, setSelectedTags] = useState([]); const [tagInput, setTagInput] = useState(""); const [availableTags, setAvailableTags] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const tagInputRef = useRef(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 { 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 ( { 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 */}