"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 (
);
}
/* ---------------------------------------------------------------------------
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 (
);
}
/* ---------------------------------------------------------------------------
File Manager Page
--------------------------------------------------------------------------- */
export default function FileManagerPage(): ReactElement {
const router = useRouter();
// Data state
const [entries, setEntries] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// View state
const [viewMode, setViewMode] = useState("list");
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("updatedAt");
const [sortOrder, setSortOrder] = useState("desc");
// Debounced search
const searchTimerRef = useRef | 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(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 => {
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 {
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 {
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 (
{/* Header */}
File Manager
Browse and manage knowledge entries
{/* Toolbar: Search + View toggle */}
{/* Search */}
{
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",
}}
/>
{/* View toggle */}
{/* Loading state */}
{isLoading && entries.length === 0 ? (
) : error !== null ? (
/* Error state */
) : entries.length === 0 ? (
/* Empty state */
{debouncedSearch
? "No entries match your search."
: "No files found. Create your first entry to get started."}
{!debouncedSearch && (
)}
) : viewMode === "list" ? (
/* List View */
{/* Table header */}
Status
Visibility
Tags
{/* Actions column header - empty */}
{/* Table rows */}
{entries.map((entry) => (
))}
) : (
/* Grid View */
{entries.map((entry) => (
))}
)}
{/* Create Dialog */}
{/* Delete Confirmation Dialog */}
{
void handleDeleteConfirm();
}}
onCancel={() => {
setDeleteTarget(null);
}}
isDeleting={isDeleting}
/>
);
}
/* ---------------------------------------------------------------------------
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 (
{
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 */}
{entry.title}
{/* Status */}
{/* Visibility */}
{/* Tags */}
{entry.tags.slice(0, 2).map((tag) => (
))}
{entry.tags.length > 2 && (
+{String(entry.tags.length - 2)}
)}
{/* Updated */}
{formatTimestamp(entry.updatedAt)}
{/* Delete action */}
);
}