1669 lines
49 KiB
TypeScript
1669 lines
49 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, 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>
|
||
);
|
||
}
|