diff --git a/apps/web/src/app/(authenticated)/files/page.tsx b/apps/web/src/app/(authenticated)/files/page.tsx index e563e1a..ec31624 100644 --- a/apps/web/src/app/(authenticated)/files/page.tsx +++ b/apps/web/src/app/(authenticated)/files/page.tsx @@ -13,7 +13,7 @@ import { ChevronUp, ChevronDown, } from "lucide-react"; -import type { KnowledgeEntryWithTags } from "@mosaic/shared"; +import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared"; import { EntryStatus, Visibility } from "@mosaic/shared"; import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; @@ -25,8 +25,12 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; -import { fetchEntries, createEntry, deleteEntry } from "@/lib/api/knowledge"; -import type { EntriesResponse, CreateEntryData, EntryFilters } from "@/lib/api/knowledge"; +import { fetchEntries, createEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge"; +import type { + EntriesResponse, + CreateEntryData, + EntryFilters, +} from "@/lib/api/knowledge"; /* --------------------------------------------------------------------------- Helpers @@ -421,6 +425,26 @@ function CreateEntryDialog({ 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(""); @@ -428,6 +452,9 @@ function CreateEntryDialog({ setStatus(EntryStatus.DRAFT); setVisibility(Visibility.PRIVATE); setFormError(null); + setSelectedTags([]); + setTagInput(""); + setShowSuggestions(false); } async function handleSubmit(e: SyntheticEvent): Promise { @@ -452,6 +479,7 @@ function CreateEntryDialog({ content: trimmedContent, status, visibility, + tags: selectedTags, }; const trimmedSummary = summary.trim(); if (trimmedSummary) { @@ -610,6 +638,210 @@ function CreateEntryDialog({ /> + {/* Tags */} +
+ +
+ {/* Selected tag chips */} + {selectedTags.map((tag) => ( + + {tag} + + + ))} + {/* Tag text input */} + { + 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 && ( +
+ {availableTags + .filter( + (t) => + t.name.toLowerCase().includes(tagInput.toLowerCase()) && + !selectedTags.includes(t.name) + ) + .slice(0, 5) + .map((tag) => ( + + ))} + {availableTags.filter( + (t) => + t.name.toLowerCase().includes(tagInput.toLowerCase()) && + !selectedTags.includes(t.name) + ).length === 0 && + tagInput.trim() && + !selectedTags.includes(tagInput.trim()) && ( + + )} +
+ )} +
+
+ {/* Status + Visibility row */}