From 25947cee52d351f9e6e7573b0fc8f7b7709ce6bb Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 17:05:48 -0600 Subject: [PATCH] feat(web): add knowledge entry editor page (KNOW-006) --- .../(authenticated)/knowledge/[slug]/page.tsx | 336 ++++++++++++++++++ .../(authenticated)/knowledge/new/page.tsx | 2 +- .../src/components/knowledge/EntryEditor.tsx | 2 +- .../components/knowledge/EntryMetadata.tsx | 2 +- .../src/components/knowledge/EntryViewer.tsx | 2 +- apps/web/src/components/knowledge/index.ts | 7 + apps/web/src/lib/api/knowledge.ts | 244 +++++++++++-- packages/shared/src/types/enums.ts | 6 + 8 files changed, 573 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx create mode 100644 apps/web/src/components/knowledge/index.ts diff --git a/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx b/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx new file mode 100644 index 0000000..9df8e78 --- /dev/null +++ b/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx @@ -0,0 +1,336 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { useRouter, useParams } from "next/navigation"; +import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared"; +import { EntryStatus, Visibility } from "@mosaic/shared"; +import { EntryViewer } from "@/components/knowledge/EntryViewer"; +import { EntryEditor } from "@/components/knowledge/EntryEditor"; +import { EntryMetadata } from "@/components/knowledge/EntryMetadata"; +import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge"; + +/** + * Knowledge Entry Detail/Editor Page + * View and edit mode for a single knowledge entry + */ +export default function EntryPage() { + const router = useRouter(); + const params = useParams(); + const slug = params.slug as string; + + const [entry, setEntry] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + // Edit state + const [editTitle, setEditTitle] = useState(""); + const [editContent, setEditContent] = useState(""); + const [editStatus, setEditStatus] = useState(EntryStatus.DRAFT); + const [editVisibility, setEditVisibility] = useState(Visibility.WORKSPACE); + const [editTags, setEditTags] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + // Load entry data + useEffect(() => { + async function loadEntry(): Promise { + try { + setIsLoading(true); + const data = await fetchEntry(slug); + setEntry(data); + setEditTitle(data.title); + setEditContent(data.content); + setEditStatus(data.status); + setEditVisibility(data.visibility); + setEditTags(data.tags.map((tag) => tag.id)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load entry"); + } finally { + setIsLoading(false); + } + } + void loadEntry(); + }, [slug]); + + // Load available tags + useEffect(() => { + async function loadTags(): Promise { + try { + const tags = await fetchTags(); + setAvailableTags(tags); + } catch (err) { + console.error("Failed to load tags:", err); + } + } + void loadTags(); + }, []); + + // Track unsaved changes + useEffect(() => { + if (!entry || !isEditing) { + setHasUnsavedChanges(false); + return; + } + + const changed = + editTitle !== entry.title || + editContent !== entry.content || + editStatus !== entry.status || + editVisibility !== entry.visibility || + JSON.stringify(editTags.sort()) !== + JSON.stringify(entry.tags.map((t) => t.id).sort()); + + setHasUnsavedChanges(changed); + }, [entry, isEditing, editTitle, editContent, editStatus, editVisibility, editTags]); + + // Warn before leaving with unsaved changes + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent): string => { + if (hasUnsavedChanges) { + e.preventDefault(); + return "You have unsaved changes. Are you sure you want to leave?"; + } + return ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [hasUnsavedChanges]); + + // Save changes + const handleSave = useCallback(async (): Promise => { + if (!entry || isSaving || !editTitle.trim() || !editContent.trim()) { + return; + } + + setIsSaving(true); + setError(null); + + try { + const updated = await updateEntry(slug, { + title: editTitle.trim(), + content: editContent.trim(), + status: editStatus, + visibility: editVisibility, + tags: editTags, + }); + + setEntry(updated); + setHasUnsavedChanges(false); + setIsEditing(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save changes"); + } finally { + setIsSaving(false); + } + }, [entry, slug, editTitle, editContent, editStatus, editVisibility, editTags, isSaving]); + + // Cmd+S / Ctrl+S to save + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if ((e.metaKey || e.ctrlKey) && e.key === "s" && isEditing) { + e.preventDefault(); + void handleSave(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleSave, isEditing]); + + const handleEdit = (): void => { + if (!entry) return; + setIsEditing(true); + }; + + const handleCancel = (): void => { + if (!entry) return; + + if ( + !hasUnsavedChanges || + confirm("You have unsaved changes. Are you sure you want to cancel?") + ) { + setEditTitle(entry.title); + setEditContent(entry.content); + setEditStatus(entry.status); + setEditVisibility(entry.visibility); + setEditTags(entry.tags.map((tag) => tag.id)); + setIsEditing(false); + setHasUnsavedChanges(false); + } + }; + + const handleDelete = async (): Promise => { + if ( + !confirm( + "Are you sure you want to delete this entry? It will be archived and can be restored later." + ) + ) { + return; + } + + try { + await deleteEntry(slug); + router.push("/knowledge"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete entry"); + } + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (error && !entry) { + return ( +
+
+

{error}

+
+
+ ); + } + + if (!entry) { + return null; + } + + return ( +
+ {/* Header */} +
+ {isEditing ? ( +
+ +
+ ) : ( + <> +

+ {entry.title} +

+
+ {/* Status Badge */} + + {entry.status} + + + {/* Visibility Badge */} + + {entry.visibility} + + + {/* Tags */} + {entry.tags.map((tag) => ( + + {tag.name} + + ))} +
+ + )} +
+ + {error && ( +
+

{error}

+
+ )} + + {/* Content */} +
+ {isEditing ? ( + + ) : ( + + )} +
+ + {/* Actions */} +
+
+ {isEditing && ( + + )} +
+ +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ + {isEditing && ( +

+ Press Cmd+S{" "} + or Ctrl+S to + save +

+ )} +
+ ); +} diff --git a/apps/web/src/app/(authenticated)/knowledge/new/page.tsx b/apps/web/src/app/(authenticated)/knowledge/new/page.tsx index ee26382..0a3d861 100644 --- a/apps/web/src/app/(authenticated)/knowledge/new/page.tsx +++ b/apps/web/src/app/(authenticated)/knowledge/new/page.tsx @@ -11,7 +11,7 @@ import { createEntry, fetchTags } from "@/lib/api/knowledge"; * New Knowledge Entry Page * Form for creating a new knowledge entry */ -export default function NewEntryPage(): JSX.Element { +export default function NewEntryPage() { const router = useRouter(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); diff --git a/apps/web/src/components/knowledge/EntryEditor.tsx b/apps/web/src/components/knowledge/EntryEditor.tsx index 73c4daa..cebf16e 100644 --- a/apps/web/src/components/knowledge/EntryEditor.tsx +++ b/apps/web/src/components/knowledge/EntryEditor.tsx @@ -10,7 +10,7 @@ interface EntryEditorProps { /** * EntryEditor - Markdown editor with live preview */ -export function EntryEditor({ content, onChange }: EntryEditorProps): JSX.Element { +export function EntryEditor({ content, onChange }: EntryEditorProps) { const [showPreview, setShowPreview] = useState(false); return ( diff --git a/apps/web/src/components/knowledge/EntryMetadata.tsx b/apps/web/src/components/knowledge/EntryMetadata.tsx index d32796a..21aba74 100644 --- a/apps/web/src/components/knowledge/EntryMetadata.tsx +++ b/apps/web/src/components/knowledge/EntryMetadata.tsx @@ -29,7 +29,7 @@ export function EntryMetadata({ onStatusChange, onVisibilityChange, onTagsChange, -}: EntryMetadataProps): JSX.Element { +}: EntryMetadataProps) { const handleTagToggle = (tagId: string): void => { if (selectedTags.includes(tagId)) { onTagsChange(selectedTags.filter((id) => id !== tagId)); diff --git a/apps/web/src/components/knowledge/EntryViewer.tsx b/apps/web/src/components/knowledge/EntryViewer.tsx index b41671c..0d07802 100644 --- a/apps/web/src/components/knowledge/EntryViewer.tsx +++ b/apps/web/src/components/knowledge/EntryViewer.tsx @@ -10,7 +10,7 @@ interface EntryViewerProps { /** * EntryViewer - Displays rendered markdown content */ -export function EntryViewer({ entry }: EntryViewerProps): JSX.Element { +export function EntryViewer({ entry }: EntryViewerProps) { return (
diff --git a/apps/web/src/components/knowledge/index.ts b/apps/web/src/components/knowledge/index.ts new file mode 100644 index 0000000..99a6056 --- /dev/null +++ b/apps/web/src/components/knowledge/index.ts @@ -0,0 +1,7 @@ +/** + * Knowledge module components + */ + +export { EntryViewer } from "./EntryViewer"; +export { EntryEditor } from "./EntryEditor"; +export { EntryMetadata } from "./EntryMetadata"; diff --git a/apps/web/src/lib/api/knowledge.ts b/apps/web/src/lib/api/knowledge.ts index de68aaa..09eed50 100644 --- a/apps/web/src/lib/api/knowledge.ts +++ b/apps/web/src/lib/api/knowledge.ts @@ -4,12 +4,14 @@ */ import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared"; -import { EntryStatus } from "@mosaic/shared"; -import { apiGet, type ApiResponse } from "./client"; +import { EntryStatus, Visibility } from "@mosaic/shared"; +import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client"; export interface EntryFilters { status?: EntryStatus; + visibility?: Visibility; tag?: string; + tags?: string[]; page?: number; limit?: number; search?: string; @@ -26,6 +28,24 @@ export interface EntriesResponse { }; } +export interface CreateEntryData { + title: string; + content: string; + summary?: string; + status?: EntryStatus; + visibility?: Visibility; + tags?: string[]; +} + +export interface UpdateEntryData { + title?: string; + content?: string; + summary?: string; + status?: EntryStatus; + visibility?: Visibility; + tags?: string[]; +} + /** * Fetch knowledge entries with optional filters */ @@ -35,23 +55,71 @@ export async function fetchEntries(filters?: EntryFilters): Promise 0) { + filters.tags.forEach((tag) => params.append("tags", tag)); + } if (filters?.page) { params.append("page", filters.page.toString()); } if (filters?.limit) { params.append("limit", filters.limit.toString()); } + if (filters?.search) { + params.append("search", filters.search); + } + if (filters?.sortBy) { + params.append("sortBy", filters.sortBy); + } + if (filters?.sortOrder) { + params.append("sortOrder", filters.sortOrder); + } const queryString = params.toString(); - const endpoint = queryString ? `/api/knowledge/entries?${queryString}` : "/api/knowledge/entries"; + const endpoint = queryString + ? `/api/knowledge/entries?${queryString}` + : "/api/knowledge/entries"; const response = await apiGet(endpoint); return response; } +/** + * Fetch a single knowledge entry by slug + */ +export async function fetchEntry(slug: string): Promise { + return apiGet(`/api/knowledge/entries/${slug}`); +} + +/** + * Create a new knowledge entry + */ +export async function createEntry(data: CreateEntryData): Promise { + return apiPost("/api/knowledge/entries", data); +} + +/** + * Update an existing knowledge entry + */ +export async function updateEntry( + slug: string, + data: UpdateEntryData +): Promise { + return apiPatch(`/api/knowledge/entries/${slug}`, data); +} + +/** + * Delete (archive) a knowledge entry + */ +export async function deleteEntry(slug: string): Promise { + await apiDelete(`/api/knowledge/entries/${slug}`); +} + /** * Fetch all knowledge tags */ @@ -73,14 +141,30 @@ export const mockEntries: KnowledgeEntryWithTags[] = [ contentHtml: "

Getting Started

Welcome to Mosaic Stack...

", summary: "A comprehensive guide to getting started with the Mosaic Stack platform.", status: EntryStatus.PUBLISHED, - visibility: "PUBLIC" as const, + visibility: Visibility.PUBLIC, createdBy: "user-1", updatedBy: "user-1", createdAt: new Date("2026-01-20"), updatedAt: new Date("2026-01-28"), tags: [ - { id: "tag-1", workspaceId: "workspace-1", name: "Tutorial", slug: "tutorial", color: "#3B82F6", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-2", workspaceId: "workspace-1", name: "Onboarding", slug: "onboarding", color: "#10B981", createdAt: new Date(), updatedAt: new Date() }, + { + id: "tag-1", + workspaceId: "workspace-1", + name: "Tutorial", + slug: "tutorial", + color: "#3B82F6", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-2", + workspaceId: "workspace-1", + name: "Onboarding", + slug: "onboarding", + color: "#10B981", + createdAt: new Date(), + updatedAt: new Date(), + }, ], }, { @@ -92,14 +176,30 @@ export const mockEntries: KnowledgeEntryWithTags[] = [ contentHtml: "

Architecture

The Mosaic Stack architecture...

", summary: "Overview of the system architecture and design patterns used in Mosaic Stack.", status: EntryStatus.PUBLISHED, - visibility: "WORKSPACE" as const, + visibility: Visibility.WORKSPACE, createdBy: "user-1", updatedBy: "user-1", createdAt: new Date("2026-01-15"), updatedAt: new Date("2026-01-27"), tags: [ - { id: "tag-3", workspaceId: "workspace-1", name: "Architecture", slug: "architecture", color: "#8B5CF6", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-4", workspaceId: "workspace-1", name: "Technical", slug: "technical", color: "#F59E0B", createdAt: new Date(), updatedAt: new Date() }, + { + id: "tag-3", + workspaceId: "workspace-1", + name: "Architecture", + slug: "architecture", + color: "#8B5CF6", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-4", + workspaceId: "workspace-1", + name: "Technical", + slug: "technical", + color: "#F59E0B", + createdAt: new Date(), + updatedAt: new Date(), + }, ], }, { @@ -111,14 +211,30 @@ export const mockEntries: KnowledgeEntryWithTags[] = [ contentHtml: "

API Docs

Work in progress...

", summary: "Comprehensive API documentation for developers.", status: EntryStatus.DRAFT, - visibility: "PRIVATE" as const, + visibility: Visibility.PRIVATE, createdBy: "user-1", updatedBy: "user-1", createdAt: new Date("2026-01-29"), updatedAt: new Date("2026-01-29"), tags: [ - { id: "tag-4", workspaceId: "workspace-1", name: "Technical", slug: "technical", color: "#F59E0B", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-5", workspaceId: "workspace-1", name: "API", slug: "api", color: "#EF4444", createdAt: new Date(), updatedAt: new Date() }, + { + id: "tag-4", + workspaceId: "workspace-1", + name: "Technical", + slug: "technical", + color: "#F59E0B", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-5", + workspaceId: "workspace-1", + name: "API", + slug: "api", + color: "#EF4444", + createdAt: new Date(), + updatedAt: new Date(), + }, ], }, { @@ -130,14 +246,30 @@ export const mockEntries: KnowledgeEntryWithTags[] = [ contentHtml: "

Deployment

How to deploy Mosaic Stack...

", summary: "Step-by-step guide for deploying Mosaic Stack to production.", status: EntryStatus.PUBLISHED, - visibility: "WORKSPACE" as const, + visibility: Visibility.WORKSPACE, createdBy: "user-1", updatedBy: "user-1", createdAt: new Date("2026-01-18"), updatedAt: new Date("2026-01-25"), tags: [ - { id: "tag-6", workspaceId: "workspace-1", name: "DevOps", slug: "devops", color: "#14B8A6", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-1", workspaceId: "workspace-1", name: "Tutorial", slug: "tutorial", color: "#3B82F6", createdAt: new Date(), updatedAt: new Date() }, + { + id: "tag-6", + workspaceId: "workspace-1", + name: "DevOps", + slug: "devops", + color: "#14B8A6", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-1", + workspaceId: "workspace-1", + name: "Tutorial", + slug: "tutorial", + color: "#3B82F6", + createdAt: new Date(), + updatedAt: new Date(), + }, ], }, { @@ -149,23 +281,87 @@ export const mockEntries: KnowledgeEntryWithTags[] = [ contentHtml: "

Meeting Notes

Old archived notes...

", summary: "Meeting notes from Q4 2025 - archived for reference.", status: EntryStatus.ARCHIVED, - visibility: "PRIVATE" as const, + visibility: Visibility.PRIVATE, createdBy: "user-1", updatedBy: "user-1", createdAt: new Date("2025-12-15"), updatedAt: new Date("2026-01-05"), tags: [ - { id: "tag-7", workspaceId: "workspace-1", name: "Meetings", slug: "meetings", color: "#6B7280", createdAt: new Date(), updatedAt: new Date() }, + { + id: "tag-7", + workspaceId: "workspace-1", + name: "Meetings", + slug: "meetings", + color: "#6B7280", + createdAt: new Date(), + updatedAt: new Date(), + }, ], }, ]; export const mockTags: KnowledgeTag[] = [ - { id: "tag-1", workspaceId: "workspace-1", name: "Tutorial", slug: "tutorial", color: "#3B82F6", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-2", workspaceId: "workspace-1", name: "Onboarding", slug: "onboarding", color: "#10B981", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-3", workspaceId: "workspace-1", name: "Architecture", slug: "architecture", color: "#8B5CF6", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-4", workspaceId: "workspace-1", name: "Technical", slug: "technical", color: "#F59E0B", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-5", workspaceId: "workspace-1", name: "API", slug: "api", color: "#EF4444", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-6", workspaceId: "workspace-1", name: "DevOps", slug: "devops", color: "#14B8A6", createdAt: new Date(), updatedAt: new Date() }, - { id: "tag-7", workspaceId: "workspace-1", name: "Meetings", slug: "meetings", color: "#6B7280", createdAt: new Date(), updatedAt: new Date() }, + { + id: "tag-1", + workspaceId: "workspace-1", + name: "Tutorial", + slug: "tutorial", + color: "#3B82F6", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-2", + workspaceId: "workspace-1", + name: "Onboarding", + slug: "onboarding", + color: "#10B981", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-3", + workspaceId: "workspace-1", + name: "Architecture", + slug: "architecture", + color: "#8B5CF6", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-4", + workspaceId: "workspace-1", + name: "Technical", + slug: "technical", + color: "#F59E0B", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-5", + workspaceId: "workspace-1", + name: "API", + slug: "api", + color: "#EF4444", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-6", + workspaceId: "workspace-1", + name: "DevOps", + slug: "devops", + color: "#14B8A6", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "tag-7", + workspaceId: "workspace-1", + name: "Meetings", + slug: "meetings", + color: "#6B7280", + createdAt: new Date(), + updatedAt: new Date(), + }, ]; diff --git a/packages/shared/src/types/enums.ts b/packages/shared/src/types/enums.ts index ead93f9..bec4d2a 100644 --- a/packages/shared/src/types/enums.ts +++ b/packages/shared/src/types/enums.ts @@ -32,6 +32,12 @@ export enum WorkspaceMemberRole { GUEST = "GUEST", } +export enum TeamMemberRole { + OWNER = "OWNER", + ADMIN = "ADMIN", + MEMBER = "MEMBER", +} + export enum ActivityAction { CREATED = "CREATED", UPDATED = "UPDATED",