Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
1437 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|