diff --git a/README.md b/README.md index 0273ec2..78beeec 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ Writing Mermaid code can sometimes be tedious without immediate visual feedback. - **Live WYSIWYG Editor:** Write Mermaid code and see the rendered diagram update in real time. - **Two-Way Visual Editing:** Click nodes, edges, and participants directly on the canvas to rename labels, change shapes/styles, reorder, or delete — all changes write back to the code instantly. -- **Diagram Management:** Create, rename, and delete diagrams from a centralized dashboard, with folder organization support. +- **Capability-Aware Creation:** Start from guided templates, create an intentional blank workspace, or create code-only Mermaid diagram types from the New Diagram flow. +- **Blank Diagram State:** Empty diagrams open as a friendly blank workspace with starter-template actions instead of a Mermaid syntax error. +- **Diagram Management:** Create, rename, delete, star, and organize diagrams from a centralized dashboard, with folder support and All Files / Recent / Starred quick views. +- **Dashboard Workflow:** Open diagrams from the dashboard in a new browser tab so the file browser stays available. - **Powered by Monaco:** Enjoy a rich code-editing experience with Mermaid syntax highlighting, powered by the Monaco Editor. - **Dark Mode Support:** Toggle between light and dark themes for comfortable viewing anytime. - **Modern Stack:** Built on Next.js, React 19, and styled with Tailwind CSS & shadcn/ui. @@ -53,11 +56,26 @@ These diagram types support full two-way interaction — you can click and manip | **Sequence Diagram** | Supported | | **Class Diagram** | Supported | | **ER Diagram** | Supported | -| **State Diagram** | Supported (2-way sync) | +| **State Diagram** | Supported | +| **Mindmap** | Supported | ### 🔲 Code Only (render + preview) -All other Mermaid diagram types are fully supported for editing and rendering but do not yet have visual canvas interaction +Code-only diagrams can be created, edited, rendered, searched, starred, organized, saved, and opened in LiveMaid, but do not yet expose visual canvas editing controls. + +The New Diagram flow currently exposes these code-only starters: + +| Diagram | Support Level | +| ----------------------- | ---------------- | +| **Gantt Chart** | Code-only render | +| **Pie Chart** | Code-only render | +| **Timeline** | Code-only render | +| **User Journey** | Code-only render | +| **Git Graph** | Code-only render | +| **Requirement Diagram** | Code-only render | +| **C4 Context** | Code-only render | + +Other Mermaid diagram syntax can still be typed or pasted into the editor as code. If LiveMaid does not have a two-way plugin for that diagram type, it is treated as code-only. ## 🐳 Deployment (Docker) diff --git a/src/app/api/diagrams/[id]/route.ts b/src/app/api/diagrams/[id]/route.ts index 9b3a994..7dbec92 100644 --- a/src/app/api/diagrams/[id]/route.ts +++ b/src/app/api/diagrams/[id]/route.ts @@ -21,10 +21,10 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (IS_DEMO_MODE) { try { const body = await request.json(); - // Rename operations (name change without code) are rejected in demo mode - if (body.name !== undefined && body.code === undefined) { + // Metadata writes (rename/move/star) are rejected in demo mode. + if (body.code === undefined) { return NextResponse.json( - { error: "Demo mode: renaming diagrams is disabled" }, + { error: "Demo mode: editing diagrams is disabled" }, { status: 403 }, ); } @@ -46,6 +46,11 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } const body = await request.json(); + const nextStarred = typeof body.starred === "boolean" ? body.starred : existing.starred; + const nextStarredAt = + typeof body.starredAt === "string" || body.starredAt === null + ? body.starredAt + : existing.starredAt; const requestedHistory = Array.isArray(body.versionHistory) ? body.versionHistory : null; const baseHistory = requestedHistory ?? existing.versionHistory ?? []; @@ -76,6 +81,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: ...body, id, // Protect ID updatedAt: new Date().toISOString(), + starred: Boolean(nextStarred), + starredAt: nextStarred ? (nextStarredAt ?? new Date().toISOString()) : null, versionHistory: nextVersionHistory, }; diff --git a/src/app/api/diagrams/route.ts b/src/app/api/diagrams/route.ts index a811c26..f222af8 100644 --- a/src/app/api/diagrams/route.ts +++ b/src/app/api/diagrams/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { getDiagrams, saveDiagram, DiagramDocument, IS_DEMO_MODE } from "@/lib/api/storage"; import { nanoid } from "nanoid"; -import { DiagramRegistry } from "@/lib/diagrams/registry"; +import { + getDiagramCatalogItem, + getDiagramTemplate, + isCreatableDiagramType, +} from "@/lib/diagrams/catalog"; export async function GET(request: Request) { try { @@ -41,26 +45,36 @@ export async function POST(request: Request) { try { const body = await request.json(); - const { name, type = "flowchart", code } = body; + const template = + typeof body.templateId === "string" ? getDiagramTemplate(body.templateId) : null; + const requestedType = + template?.type ?? (typeof body.type === "string" ? body.type : "flowchart"); + const { name, code } = body; const folderId = typeof body.folderId === "string" ? body.folderId : null; if (!name) { return NextResponse.json({ error: "Name is required" }, { status: 400 }); } - const plugin = DiagramRegistry[type] || DiagramRegistry["flowchart"]; - const defaultCode = plugin.defaultCode; - const finalCode = code !== undefined ? code : defaultCode; + if (!isCreatableDiagramType(requestedType)) { + return NextResponse.json({ error: "Unsupported diagram type" }, { status: 400 }); + } + + const catalogItem = getDiagramCatalogItem(requestedType); + const finalCode = typeof code === "string" ? code : (template?.code ?? catalogItem.defaultCode); + const now = new Date().toISOString(); const newDiagram: DiagramDocument = { id: nanoid(), name, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: now, + updatedAt: now, deletedAt: null, code: finalCode, - type, + type: requestedType, folderId, + starred: false, + starredAt: null, subPages: [], comments: [], versionHistory: [], diff --git a/src/app/api/folders/[id]/route.ts b/src/app/api/folders/[id]/route.ts index fce0751..f845dbc 100644 --- a/src/app/api/folders/[id]/route.ts +++ b/src/app/api/folders/[id]/route.ts @@ -69,6 +69,15 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: next.parentId = newParentId; } + if (typeof body.starred === "boolean") { + next.starred = body.starred; + next.starredAt = body.starred + ? typeof body.starredAt === "string" + ? body.starredAt + : new Date().toISOString() + : null; + } + next.updatedAt = new Date().toISOString(); await saveFolder(next); return NextResponse.json(next); diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts index 9c5ab57..a4f3285 100644 --- a/src/app/api/folders/route.ts +++ b/src/app/api/folders/route.ts @@ -41,6 +41,8 @@ export async function POST(request: Request) { createdAt: now, updatedAt: now, deletedAt: null, + starred: false, + starredAt: null, }; await saveFolder(folder); diff --git a/src/components/CreateDiagramDialog.tsx b/src/components/CreateDiagramDialog.tsx new file mode 100644 index 0000000..8c676be --- /dev/null +++ b/src/components/CreateDiagramDialog.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Binary, + Boxes, + Braces, + GitBranch, + ListTree, + Network, + PieChart, + Route, + Share2, + Table2, + TimerReset, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + DIAGRAM_CATALOG, + DIAGRAM_TEMPLATES, + getDiagramCatalogItem, + type DiagramCatalogItem, +} from "@/lib/diagrams/catalog"; + +export interface CreateDiagramPayload { + name: string; + type: string; + templateId?: string; + code?: string; +} + +interface CreateDiagramDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultName?: string; + onCreate: (payload: CreateDiagramPayload) => void | Promise; +} + +const PRIMARY_TYPES = [ + "flowchart", + "sequence", + "classDiagram", + "erDiagram", + "stateDiagram", + "mindmap", +]; + +const ICONS = { + flowchart: Route, + sequence: Share2, + classDiagram: Boxes, + erDiagram: Table2, + stateDiagram: Binary, + mindmap: ListTree, + blank: Braces, + gantt: TimerReset, + pie: PieChart, + timeline: Network, + journey: GitBranch, + gitGraph: GitBranch, + requirementDiagram: Braces, + C4Context: Network, +} as const; + +function getOptionIcon(id: string) { + return ICONS[id as keyof typeof ICONS] ?? Braces; +} + +function getStarterTemplate(type: string) { + return DIAGRAM_TEMPLATES.find((template) => template.type === type) ?? null; +} + +function BlueprintPreview({ selected }: { selected: DiagramCatalogItem }) { + const isSequence = selected.id === "sequence"; + const isData = selected.id === "classDiagram" || selected.id === "erDiagram"; + const isState = selected.id === "stateDiagram"; + const isMindmap = selected.id === "mindmap"; + + return ( +
+
+
+ {isSequence ? ( +
+ {["User", "LiveMaid", "Mermaid"].map((label) => ( +
+
+ {label} +
+
+
+ ))} +
+ ) : isData ? ( +
+
+ Entity +
+
+
+ Field +
+
+ ) : isState ? ( +
+
+
+
+ Active +
+
+
+ Done +
+
+ ) : isMindmap ? ( +
+
+ Idea +
+
+
+ Branch +
+
+ Branch +
+
+
+ ) : ( +
+
+ Start +
+
+
+
+
+ End +
+
+ )} +
+
+ ); +} + +export function CreateDiagramDialog({ + open, + onOpenChange, + defaultName = "Untitled Diagram", + onCreate, +}: CreateDiagramDialogProps) { + const [name, setName] = useState(defaultName); + const [selectedId, setSelectedId] = useState(PRIMARY_TYPES[0]); + + const { twoWayOptions, codeOnlyOptions } = useMemo(() => { + const catalog = DIAGRAM_CATALOG.filter((item) => item.id !== "blank"); + return { + twoWayOptions: catalog.filter((item) => item.capability === "two-way"), + codeOnlyOptions: catalog.filter((item) => item.capability === "code-only"), + }; + }, []); + const selected = getDiagramCatalogItem(selectedId); + const selectedTemplate = getStarterTemplate(selected.id); + const canCreate = name.trim().length > 0; + + const reset = () => { + setName(defaultName); + setSelectedId(PRIMARY_TYPES[0]); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) reset(); + onOpenChange(nextOpen); + }; + + const submit = async () => { + const trimmedName = name.trim(); + if (!trimmedName) return; + + await onCreate({ + name: trimmedName, + type: selectedTemplate?.type ?? selected.id, + templateId: selectedTemplate?.id, + code: selectedTemplate ? undefined : selected.defaultCode, + }); + reset(); + }; + + const renderOption = (item: DiagramCatalogItem) => { + const Icon = getOptionIcon(item.id); + const active = selectedId === item.id; + + return ( + + ); + }; + + return ( + + + + Create diagram + + +
+
+
+ + setName(e.target.value)} + placeholder="Untitled Diagram" + className="mt-2" + onKeyDown={(e) => { + if (e.key === "Enter") void submit(); + }} + /> +
+ +
+

What are you trying to map?

+ +

+ Editable on canvas +

+
+ {twoWayOptions.map((item) => renderOption(item))} +
+ +

+ Code-only +

+
+ {codeOnlyOptions.map((item) => renderOption(item))} +
+
+ + +
+ + +
+ + + + + +
+
+ ); +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index ca4dddf..10ceac5 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -2,10 +2,10 @@ import { useState, useEffect, useMemo, useRef } from "react"; import { useTheme } from "next-themes"; -import Link from "next/link"; import { DiagramCard, DiagramDocument } from "@/components/DiagramCard"; import { FolderCard, Folder } from "@/components/FolderCard"; import { FolderTree } from "@/components/FolderTree"; +import { CreateDiagramDialog, type CreateDiagramPayload } from "@/components/CreateDiagramDialog"; import { Button } from "@/components/ui/button"; import { BrandLogo } from "@/components/BrandLogo"; import { @@ -26,10 +26,9 @@ import { LayoutGrid, List, Settings, + Star, } from "lucide-react"; -import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { DiagramRegistry } from "@/lib/diagrams/registry"; import { useUserPreferences } from "@/hooks/useUserPreferences"; import { Skeleton } from "@/components/ui/skeleton"; @@ -108,16 +107,15 @@ export default function Dashboard({ const [searchQuery, setSearchQuery] = useState(""); const [searchLoading, setSearchLoading] = useState(false); const searchTimer = useRef(null); + const searchInputRef = useRef(null); const [loading, setLoading] = useState(true); - const [isNavigating, setIsNavigating] = useState(false); const [displayCount, setDisplayCount] = useState(12); const sentinelRef = useRef(null); - const router = useRouter(); // Dialog states const [isCreateOpen, setIsCreateOpen] = useState(false); const [createName, setCreateName] = useState(""); - const [createType, setCreateType] = useState("blank"); + const [quickView, setQuickView] = useState<"all" | "recent" | "starred" | null>(null); const [isRenameOpen, setIsRenameOpen] = useState(false); const [renameId, setRenameId] = useState(""); @@ -209,12 +207,11 @@ export default function Dashboard({ const openCreateDialog = () => { setCreateName("Untitled Diagram"); - setCreateType("blank"); setIsCreateOpen(true); }; - const handleCreateSubmit = async () => { - if (!createName.trim()) return; + const handleCreateSubmit = async (payload: CreateDiagramPayload) => { + if (!payload.name.trim()) return; setIsCreateOpen(false); try { @@ -222,15 +219,17 @@ export default function Dashboard({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: createName, - type: createType, - code: createType === "blank" ? "" : DiagramRegistry[createType]?.defaultCode, + name: payload.name, + type: payload.type, + templateId: payload.templateId, + code: payload.code, folderId: currentFolderId, }), }); if (!res.ok) throw new Error("Failed to create"); const newDoc = await res.json(); - handleNavigate(`/editor/${newDoc.id}`); + setDiagrams((prev) => [newDoc, ...prev]); + window.open(`/editor/${newDoc.id}`, "_blank", "noopener,noreferrer"); } catch (error) { toast.error("Failed to create diagram"); } @@ -241,13 +240,6 @@ export default function Dashboard({ setIsDeleteOpen(true); }; - const handleNavigate = (url: string) => { - setIsNavigating(true); - setTimeout(() => { - router.push(url); - }, 400); - }; - const handleDeleteConfirm = async () => { setIsDeleteOpen(false); try { @@ -398,6 +390,52 @@ export default function Dashboard({ } }; + const handleToggleDiagramStar = async (id: string, starred: boolean) => { + if (isDemo) { + toast.info("Demo mode — this is read only, changes won't be saved"); + return; + } + const starredAt = starred ? new Date().toISOString() : null; + const previous = diagrams; + setDiagrams((prev) => prev.map((d) => (d.id === id ? { ...d, starred, starredAt } : d))); + try { + const res = await fetch(`/api/diagrams/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ starred, starredAt }), + }); + if (!res.ok) throw new Error("Failed to update star"); + const updated = await res.json(); + setDiagrams((prev) => prev.map((d) => (d.id === id ? updated : d))); + } catch { + setDiagrams(previous); + toast.error("Failed to update diagram star"); + } + }; + + const handleToggleFolderStar = async (id: string, starred: boolean) => { + if (isDemo) { + toast.info("Demo mode — this is read only, changes won't be saved"); + return; + } + const starredAt = starred ? new Date().toISOString() : null; + const previous = folders; + setFolders((prev) => prev.map((f) => (f.id === id ? { ...f, starred, starredAt } : f))); + try { + const res = await fetch(`/api/folders/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ starred, starredAt }), + }); + if (!res.ok) throw new Error("Failed to update star"); + const updated = await res.json(); + setFolders((prev) => prev.map((f) => (f.id === id ? updated : f))); + } catch { + setFolders(previous); + toast.error("Failed to update folder star"); + } + }; + // Open the move-confirmation dialog (non-demo only). Captures the target names so the dialog // can display them before the user confirms. const requestMoveDiagram = (id: string, folderId: string | null) => { @@ -467,6 +505,37 @@ export default function Dashboard({ setSearchLoading(false); }; + useEffect(() => { + const handleSearchShortcut = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const isTypingTarget = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target?.isContentEditable; + + if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isTypingTarget) { + e.preventDefault(); + searchInputRef.current?.focus(); + return; + } + + if (e.key === "Escape" && document.activeElement === searchInputRef.current) { + if (searchInput.trim() || searchQuery.trim()) { + e.preventDefault(); + if (searchTimer.current) clearTimeout(searchTimer.current); + setSearchInput(""); + setSearchQuery(""); + setSearchLoading(false); + } else { + searchInputRef.current?.blur(); + } + } + }; + + document.addEventListener("keydown", handleSearchShortcut); + return () => document.removeEventListener("keydown", handleSearchShortcut); + }, [searchInput, searchQuery]); + // Breadcrumb ancestor chain for the current folder (root → … → current). const breadcrumb = useMemo(() => { const chain: Folder[] = []; @@ -529,11 +598,21 @@ export default function Dashboard({ if (id === currentFolderId && searchInput.trim() === "" && searchQuery.trim() === "") return; resetSearch(); setFolderLoading(true); + setQuickView(null); setCurrentFolderId(id); if (folderSwitchTimer.current) clearTimeout(folderSwitchTimer.current); folderSwitchTimer.current = setTimeout(() => setFolderLoading(false), 350); }; + const selectQuickView = (view: "all" | "recent" | "starred") => { + resetSearch(); + setFolderLoading(true); + setQuickView(view); + setCurrentFolderId(null); + if (folderSwitchTimer.current) clearTimeout(folderSwitchTimer.current); + folderSwitchTimer.current = setTimeout(() => setFolderLoading(false), 350); + }; + // Clean up the folder-switch timer on unmount. useEffect( () => () => { @@ -542,23 +621,40 @@ export default function Dashboard({ [], ); - // Folders shown in the current view. While searching, match by name across ALL folders. + // Folders shown in the current view. Searching filters within that same view so the search scope + // matches the selected sidebar/folder context. const visibleFolders = useMemo(() => { - const list = isSearching - ? folders.filter((f) => f.name.toLowerCase().includes(searchQuery.trim().toLowerCase())) - : folders.filter((f) => (f.parentId ?? null) === currentFolderId); + const q = searchQuery.trim().toLowerCase(); + const inScope = + quickView === "starred" + ? folders.filter((f) => f.starred) + : quickView === "recent" + ? [] + : quickView === "all" + ? folders + : folders.filter((f) => (f.parentId ?? null) === currentFolderId); + const list = isSearching ? inScope.filter((f) => f.name.toLowerCase().includes(q)) : inScope; return list.sort((a, b) => a.name.localeCompare(b.name)); - }, [folders, currentFolderId, isSearching, searchQuery]); + }, [folders, currentFolderId, isSearching, searchQuery, quickView]); const filteredDiagrams = useMemo(() => { const q = searchQuery.trim().toLowerCase(); - const inScope = isSearching - ? diagrams.filter((d) => d.name.toLowerCase().includes(q)) - : // The workspace behaves like a file-explorer root: it shows the folders plus only the - // diagrams that live directly at this level (unfiled at root, or owned by the current - // folder). Diagrams moved into a folder leave the root view and surface inside that folder. - diagrams.filter((d) => (d.folderId ?? null) === currentFolderId); - const sorted = [...inScope]; + const inScope = + quickView === "starred" + ? diagrams.filter((d) => d.starred) + : quickView === "recent" + ? [...diagrams] + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, 20) + : quickView === "all" + ? diagrams + : // The workspace behaves like a file-explorer root: it shows the folders plus only the + // diagrams that live directly at this level (unfiled at root, or owned by the current + // folder). Diagrams moved into a folder leave the root view and surface inside that folder. + diagrams.filter((d) => (d.folderId ?? null) === currentFolderId); + const sorted = isSearching + ? inScope.filter((d) => d.name.toLowerCase().includes(q)) + : [...inScope]; if (sortBy === "name") { sorted.sort((a, b) => a.name.localeCompare(b.name)); } else if (sortBy === "created") { @@ -567,7 +663,7 @@ export default function Dashboard({ sorted.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } return sorted; - }, [diagrams, searchQuery, isSearching, currentFolderId, sortBy]); + }, [diagrams, searchQuery, isSearching, currentFolderId, sortBy, quickView]); // Lazy-load: show 12 items at a time, reveal next batch via IntersectionObserver. const LAZY_BATCH = 12; @@ -579,7 +675,7 @@ export default function Dashboard({ setDisplayCount(12); setLoadingMore(false); if (loadMoreTimer.current) clearTimeout(loadMoreTimer.current); - }, [currentFolderId, searchQuery, sortBy]); + }, [currentFolderId, searchQuery, sortBy, quickView]); const displayedDiagrams = useMemo(() => { return filteredDiagrams.slice(0, displayCount); @@ -623,6 +719,39 @@ export default function Dashboard({ [], ); + const headerTitle = + quickView === "all" + ? "All Files" + : quickView === "recent" + ? "Recent Diagrams" + : quickView === "starred" + ? "Starred Items" + : currentFolderId + ? (breadcrumb[breadcrumb.length - 1]?.name ?? "Your Diagrams") + : "Your Diagrams"; + + const quickViewBreadcrumb = + quickView === "all" + ? "All Files" + : quickView === "recent" + ? "Recent" + : quickView === "starred" + ? "Starred" + : null; + + const searchPlaceholder = + quickView === "all" + ? "Search all files" + : quickView === "recent" + ? "Search recent diagrams" + : quickView === "starred" + ? "Search starred items" + : currentFolderId + ? `Search in ${headerTitle}` + : "Search workspace"; + const searchResultCount = visibleFolders.length + filteredDiagrams.length; + const searchResultLabel = `${searchResultCount} result${searchResultCount === 1 ? "" : "s"}`; + return (
{/* Demo-mode notification bar — full-width, sticky to the very top above the sidebar + content. */} @@ -645,6 +774,35 @@ export default function Dashboard({ {/* Sidebar navigation: Recent + Folders */}
+ {/* Quick views */} +
+
+ + + Quick views + +
+
+ {[ + { id: "all" as const, label: "All Files", icon: FileText }, + { id: "recent" as const, label: "Recent", icon: Clock }, + { id: "starred" as const, label: "Starred", icon: Star }, + ].map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+
+ {/* Recent */}
@@ -658,20 +816,22 @@ export default function Dashboard({ ) : (
{recentDiagrams.map((d) => ( - + ))}
)} @@ -776,89 +936,162 @@ export default function Dashboard({ LiveMaid - {isNavigating && ( -
-
- -

- Loading Workspace... -

-
-
- )} - {/* Main Content */} -
- {/* Header: breadcrumb + title on top, controls in a full-width wrapping row below */} -
- {/* Breadcrumb navigation */} - + +

+ {headerTitle} +

+
+ +
+
+ {!isDemo && ( + + )} + + {isDemo ? ( + + + }> + + + Read-only in demo mode + + + ) : ( + + )} +
+ + + } - }} - className={`max-w-[160px] truncate rounded px-1.5 py-0.5 transition-colors hover:bg-accent hover:text-foreground ${f.id === currentFolderId ? "text-foreground font-medium" : ""} ${breadcrumbDragOverId === f.id ? "ring-2 ring-indigo-500/50 bg-indigo-500/10" : ""}`} - > - {f.name} - - - ))} - - -
-

- {currentFolderId - ? (breadcrumb[breadcrumb.length - 1]?.name ?? "Your Diagrams") - : "Your Diagrams"} -

- -
- {/* Row 1: filters — how existing diagrams are displayed (search / sort / view). */} -
-
+ > + + Create + + + {!isDemo && ( + + New Folder + + )} + + New Diagram + + + +
+
+ +
+
+
setSearchInput(e.target.value)} - placeholder="Search diagrams" - className="pl-9 pr-9 h-10" + placeholder={searchPlaceholder} + aria-label="Search dashboard" + className="h-10 pl-9 pr-9" /> {searchLoading ? ( @@ -872,54 +1105,68 @@ export default function Dashboard({ ) : null}
+ {(searchInput.trim() || isSearching) && ( +

+ {searchLoading + ? `Searching ${headerTitle.toLowerCase()}...` + : `${searchResultLabel} for "${searchQuery || searchInput}"`} +

+ )} +
- {/* Sort dropdown */} - - - } - > - - {sortBy === "edited" - ? "Last edited" - : sortBy === "created" - ? "Date created" - : "Name"} - - - setUserPref("sortBy", "edited")} - className="cursor-pointer" - > - Last edited - - setUserPref("sortBy", "created")} - className="cursor-pointer" - > - Date created - - setUserPref("sortBy", "name")} - className="cursor-pointer" +
+
+ + + } > - Name - - - + + + {sortBy === "edited" + ? "Last edited" + : sortBy === "created" + ? "Date created" + : "Name"} + + + + setUserPref("sortBy", "edited")} + className="cursor-pointer" + > + Last edited + + setUserPref("sortBy", "created")} + className="cursor-pointer" + > + Date created + + setUserPref("sortBy", "name")} + className="cursor-pointer" + > + Name + + + - {/* View toggle: switch the file viewer between grid and list layouts. */} -
+
- {/* end Row 1 */} - - {/* Row 2: actions — create new content (folder / diagram), primary CTA anchored right. */} -
- {!isDemo && ( - - )} - - {isDemo ? ( - - - }> - - - Read-only in demo mode - - - ) : ( - - )} -
- {/* end Row 2 */}
- {/* end header */} {loading || folderLoading || searchLoading ? (
@@ -1046,12 +1251,18 @@ export default function Dashboard({

- {diagrams.length === 0 ? "No diagrams yet" : "No diagrams found"} + {diagrams.length === 0 + ? "No diagrams yet" + : isSearching + ? `No results found for "${searchQuery}"` + : "No diagrams found"}

{diagrams.length === 0 ? "Get started by creating your first diagram. Choose a template above or create from scratch." - : "Try a different search term or create a new diagram."} + : isSearching + ? "Try a different name, diagram type, or folder." + : "Try a different search term or create a new diagram."}

{isDemo ? ( @@ -1107,6 +1318,7 @@ export default function Dashboard({ onRename={openRenameFolderDialog} onDelete={openDeleteFolderDialog} onMove={handleMoveFolder} + onToggleStar={handleToggleFolderStar} onDropDiagram={handleFolderDrop} moveTargets={moveTargets} canMove={ALLOW_NESTED_FOLDERS} @@ -1139,8 +1351,8 @@ export default function Dashboard({ diagram={diagram} onRename={openRenameDialog} onDelete={openDeleteDialog} - onNavigate={handleNavigate} onMove={requestMoveDiagram} + onToggleStar={handleToggleDiagramStar} moveTargets={moveTargets} isDemo={isDemo} view={viewMode} @@ -1159,62 +1371,12 @@ export default function Dashboard({
- {/* Create Dialog */} - - - - Create New Diagram - -
- setCreateName(e.target.value)} - placeholder="Diagram name" - autoFocus - onKeyDown={(e) => e.key === "Enter" && handleCreateSubmit()} - /> -
-

Diagram Template

-
- {[ - { - id: "blank", - label: "Blank", - description: "Start with an empty canvas and write your own Mermaid code.", - }, - { id: "flowchart", label: "Flowchart" }, - { id: "sequence", label: "Sequence" }, - { id: "classDiagram", label: "Class" }, - { id: "erDiagram", label: "ER" }, - { id: "stateDiagram", label: "State" }, - { id: "mindmap", label: "Mindmap" }, - ].map((t) => ( - - ))} -
-
-
- - - - -
-
+ {/* Rename Dialog */} diff --git a/src/components/DiagramCard.tsx b/src/components/DiagramCard.tsx index 16e93bc..423c08d 100644 --- a/src/components/DiagramCard.tsx +++ b/src/components/DiagramCard.tsx @@ -11,10 +11,12 @@ import { Code2, MoreVertical, FolderInput, + Star, } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { useEffect, useRef } from "react"; import { determineDiagramType, diagramTypeLabel } from "@/lib/diagrams/utils"; +import { getDiagramCapability } from "@/lib/diagrams/catalog"; import { useMermaidPreview } from "@/hooks/useMermaidPreview"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { @@ -44,6 +46,8 @@ export interface DiagramDocument { type: string; code?: string; folderId?: string | null; + starred?: boolean; + starredAt?: string | null; } export function DiagramCard({ @@ -52,6 +56,7 @@ export function DiagramCard({ onDelete, onNavigate, onMove, + onToggleStar, moveTargets, isDemo = false, view = "grid", @@ -59,8 +64,9 @@ export function DiagramCard({ diagram: DiagramDocument; onRename: (id: string, name: string) => void; onDelete: (id: string) => void; - onNavigate: (url: string) => void; + onNavigate?: (url: string) => void; onMove?: (id: string, folderId: string | null) => void; + onToggleStar?: (id: string, starred: boolean) => void; moveTargets?: { id: string | null; name: string; depth: number }[]; isDemo?: boolean; view?: "grid" | "list"; @@ -73,13 +79,8 @@ export function DiagramCard({ const showPreviewLoader = Boolean(diagram.code) && isCompiling; const parsedType = diagram.code ? determineDiagramType(diagram.code) : diagram.type; - const isSupported = - parsedType === "graph" || - parsedType === "flowchart" || - parsedType === "sequence" || - parsedType === "classDiagram" || - parsedType === "erDiagram" || - parsedType === "stateDiagram"; + const isSupported = getDiagramCapability(parsedType) === "two-way"; + const href = `/editor/${diagram.id}`; const handleDragStart = (e: React.DragEvent) => { e.dataTransfer.setData("application/x-livemaid-diagram", diagram.id); @@ -87,6 +88,18 @@ export function DiagramCard({ }; // Action buttons (edit / move / delete) shared between the grid card and the list row. + const starAction = !isDemo && onToggleStar && ( + + ); + const actions = isDemo ? ( @@ -245,10 +258,17 @@ export function DiagramCard({ onNavigate(`/editor/${diagram.id}`)} style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }} - className="flex flex-row items-center gap-4 px-4 py-3 bg-background border-border hover:border-accent-foreground/30 hover:shadow-md transition-all duration-200 group cursor-pointer" + className="relative flex flex-row items-center gap-4 px-4 py-3 bg-background border-border hover:border-accent-foreground/30 hover:shadow-md transition-all duration-200 group cursor-pointer" > + onNavigate?.(href)} + />
@@ -263,11 +283,19 @@ export function DiagramCard({ {editedLabel}
e.stopPropagation()} > {actions}
+ {starAction && ( +
e.stopPropagation()} + > + {starAction} +
+ )} ); } @@ -278,29 +306,36 @@ export function DiagramCard({ draggable={!!onMove} onDragStart={handleDragStart} style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" }} - className="flex flex-col h-full bg-background border-border hover:border-accent-foreground/30 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 group cursor-pointer" + className="relative flex flex-col h-full bg-background border-border hover:border-accent-foreground/30 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 group cursor-pointer" > +
onNavigate?.(href)} + />
{diagram.name} -
+
{actions}
+ {starAction && ( +
e.stopPropagation()}> + {starAction} +
+ )}
{typeBadge}
-
{ - e.preventDefault(); - onNavigate(`/editor/${diagram.id}`); - }} - > + diff --git a/src/components/FolderCard.tsx b/src/components/FolderCard.tsx index ba3dd3d..dfe6737 100644 --- a/src/components/FolderCard.tsx +++ b/src/components/FolderCard.tsx @@ -9,6 +9,7 @@ import { Pencil, Trash2, FolderOpen, + Star, } from "lucide-react"; import { useState } from "react"; import { @@ -28,6 +29,8 @@ export interface Folder { parentId: string | null; createdAt: string; updatedAt: string; + starred?: boolean; + starredAt?: string | null; } export function FolderCard({ @@ -37,6 +40,7 @@ export function FolderCard({ onRename, onDelete, onMove, + onToggleStar, onDropDiagram, moveTargets, canMove = true, @@ -49,6 +53,7 @@ export function FolderCard({ onRename: (id: string, name: string) => void; onDelete: (id: string) => void; onMove: (id: string, parentId: string | null) => void; + onToggleStar?: (id: string, starred: boolean) => void; onDropDiagram: (diagramId: string, folderId: string) => void; moveTargets: { id: string | null; name: string; depth: number }[]; canMove?: boolean; @@ -90,6 +95,15 @@ export function FolderCard({ + {onToggleStar && ( + onToggleStar(folder.id, !folder.starred)} + className="cursor-pointer gap-2" + > + + {folder.starred ? "Unstar" : "Star"} + + )} onOpen(folder.id)} className="cursor-pointer gap-2"> Open @@ -133,6 +147,20 @@ export function FolderCard({ const countLabel = childCount === 0 ? "Empty" : `${childCount} item${childCount === 1 ? "" : "s"}`; + const starButton = onToggleStar && !isDemo && ( + + ); // ---- List view: a compact horizontal row ------------------------------- if (view === "list") { @@ -151,11 +179,9 @@ export function FolderCard({

{folder.name}

{countLabel}

-
e.stopPropagation()} - > - {menu} +
e.stopPropagation()}> + {starButton} +
{menu}
); @@ -166,18 +192,23 @@ export function FolderCard({ onOpen(folder.id)} {...dropProps} - className={`group relative flex flex-col items-center justify-center text-center gap-3 p-6 h-full min-h-[140px] cursor-pointer bg-background border-border transition-all duration-200 hover:border-accent-foreground/30 hover:shadow-lg hover:-translate-y-1 ${ + className={`group relative flex flex-col items-center justify-center text-center gap-3 px-4 py-6 h-full min-h-[140px] cursor-pointer bg-background border-border transition-all duration-200 hover:border-accent-foreground/30 hover:shadow-lg hover:-translate-y-1 ${ isDragOver ? "border-indigo-500 ring-2 ring-indigo-500/40 bg-indigo-500/5" : "" }`} > - {/* Three-dot menu — top-right corner, like diagram cards */} + {/* Top-right actions: keep the star fixed; reveal the menu to its left on hover. */} {!isDemo && ( -
e.stopPropagation()} - > - {menu} -
+ <> +
e.stopPropagation()}> + {starButton} +
+
e.stopPropagation()} + > + {menu} +
+ )} {/* Centered folder icon + name */} diff --git a/src/components/editor/EditorCanvas.tsx b/src/components/editor/EditorCanvas.tsx index b27d6ae..adff4da 100644 --- a/src/components/editor/EditorCanvas.tsx +++ b/src/components/editor/EditorCanvas.tsx @@ -67,6 +67,7 @@ import { BASIC_SHAPES, EXTENDED_SHAPES, type ShapeOption } from "@/lib/diagrams/ import type { ConnectionState, ShapePicker } from "@/hooks/useCanvasInteraction"; import type { DiagramComment } from "@/lib/api/storage"; import { getSortedSequenceNoteTextElements } from "@/lib/diagrams/sequenceNotes"; +import { EmptyCanvas } from "./EmptyCanvas"; const DEFAULT_CANVAS_INITIAL_SCALE = 2.75; @@ -74,6 +75,7 @@ interface EditorCanvasProps { code: string; parseError: string | null; svgContent: string; + isBlankDiagram?: boolean; isLocked: boolean; setIsLocked: (locked: boolean) => void; isCommentMode?: boolean; @@ -387,6 +389,7 @@ export function EditorCanvas({ code, parseError, svgContent, + isBlankDiagram = false, isLocked, setIsLocked, containerRef, @@ -1847,6 +1850,11 @@ export function EditorCanvas({ backgroundSize: "24px 24px", }} /> + {isBlankDiagram && ( +
+ +
+ )} - {parseError && !(currentType === "mindmap" && !mindmapHasNodes) && ( -
e.stopPropagation()} + {parseError && + !isBlankDiagram && + !(currentType === "mindmap" && !mindmapHasNodes) && ( +
e.stopPropagation()} + /> + )} + + {!isBlankDiagram && ( + )} - - void; onVersionHistory: () => void; onComments: () => void; + currentType?: string; } export function EditorHeader({ @@ -58,6 +63,7 @@ export function EditorHeader({ onExport, onVersionHistory, onComments, + currentType, }: EditorHeaderProps) { // `resolvedTheme` (NOT `theme`): `theme` is the literal setting ("system") on a fresh load, so it // doesn't reflect the actual dark/light in effect. Use the resolved value so the toggle's state @@ -102,27 +108,6 @@ export function EditorHeader({ setDraftName(doc?.name || ""); }; - const handleNewDiagramInNewTab = async () => { - if (!doc) return; - try { - const res = await fetch("/api/diagrams", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "New Diagram", - code: `graph TD\n A[Start] --> B[End]`, - }), - }); - if (res.ok) { - const newDiagram = await res.json(); - window.open(`/editor/${newDiagram.id}`, "_blank", "noopener,noreferrer"); - } - } catch { - // fallback to the existing dialog flow - onNewDiagram(); - } - }; - const prepareDuplicateLink = (anchor: HTMLAnchorElement) => { if (anchor.dataset.duplicatePrepared === "true") return true; const duplicateUrl = onDuplicate(); @@ -136,6 +121,8 @@ export function EditorHeader({ const actionButtonClass = "flex h-9 w-[140px] items-center justify-center gap-2 rounded-md border border-border px-3 text-foreground transition-colors hover:bg-accent"; + const displayType = currentType || doc?.type || "blank"; + const capability = getDiagramCapability(displayType); useEffect(() => { if (isEditingName) { @@ -164,7 +151,7 @@ export function EditorHeader({ { e.preventDefault(); - handleNewDiagramInNewTab(); + onNewDiagram(); }} className="cursor-pointer rounded-md px-3 py-2.5 text-[15px] focus:bg-accent focus:text-accent-foreground flex items-center gap-2" > @@ -345,6 +332,23 @@ export function EditorHeader({ +
+ ); + })} +
+
+ +
+

+ Code only +

+
+ {codeOnlyOptions.map((item) => { + const Icon = getIcon(item.id); + const active = selectedId === item.id; + return ( + + ); + })} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/editor/LiveMaidEditor.tsx b/src/components/editor/LiveMaidEditor.tsx index f0e0a80..b8739dc 100644 --- a/src/components/editor/LiveMaidEditor.tsx +++ b/src/components/editor/LiveMaidEditor.tsx @@ -31,6 +31,7 @@ import { EditorCodePanel } from "./EditorCodePanel"; import { EditorCanvas } from "./EditorCanvas"; import { CommentSidebar } from "./comments/CommentSidebar"; import { ClassTextEditor } from "./ClassTextEditor"; +import { CreateDiagramDialog, type CreateDiagramPayload } from "@/components/CreateDiagramDialog"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { Loader2, @@ -233,6 +234,7 @@ export function LiveMaidEditor({ currentFont, setCurrentFont, parseError, + isBlankDiagram, renderIdRef, handleCodeChange, hasUnsavedChangesRef, @@ -3773,15 +3775,18 @@ export function LiveMaidEditor({ return `/editor/${doc.id}/duplicate?token=${encodeURIComponent(token)}`; }; - const handleCreateSubmit = async () => { - if (!createName.trim()) return; + const handleCreateSubmit = async (payload: CreateDiagramPayload) => { + if (!payload.name.trim()) return; try { const res = await fetch("/api/diagrams", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - name: createName.trim(), - code: `graph TD\n A[Start] --> B[End]`, + name: payload.name, + type: payload.type, + templateId: payload.templateId, + code: payload.code, + folderId: doc?.folderId ?? null, }), }); if (res.ok) { @@ -4132,6 +4137,7 @@ export function LiveMaidEditor({ )} - {/* Create Dialog */} - - - - Create New Diagram - -
- setCreateName(e.target.value)} - placeholder="Diagram name" - autoFocus - onKeyDown={(e) => e.key === "Enter" && handleCreateSubmit()} - /> -
- - - - -
-
+ {/* Rename Dialog */} diff --git a/src/hooks/useEditorState.ts b/src/hooks/useEditorState.ts index 9b5dab2..63cc5ce 100644 --- a/src/hooks/useEditorState.ts +++ b/src/hooks/useEditorState.ts @@ -40,6 +40,14 @@ export function useEditorState(documentId: string, isDemo: boolean = false) { }, []); const renderMermaid = useCallback(async (mermaidCode: string, onResetSelection?: () => void) => { + if (mermaidCode.trim().length === 0) { + setParseError(null); + setSvgContent(""); + renderIdRef.current = null; + if (onResetSelection) onResetSelection(); + return; + } + try { setParseError(null); mermaid.initialize({ @@ -211,6 +219,7 @@ export function useEditorState(documentId: string, isDemo: boolean = false) { setCurrentFont, parseError, setParseError, + isBlankDiagram: code.trim().length === 0, renderIdRef, handleCodeChange, hasUnsavedChangesRef, diff --git a/src/lib/api/storageTypes.ts b/src/lib/api/storageTypes.ts index f39563b..b1ca91d 100644 --- a/src/lib/api/storageTypes.ts +++ b/src/lib/api/storageTypes.ts @@ -16,8 +16,10 @@ export interface DiagramDocument { updatedAt: string; deletedAt: string | null; code: string; - type: "flowchart" | "sequence" | "class"; + type: string; folderId: string | null; + starred?: boolean; + starredAt?: string | null; subPages: { id: string; name: string; code: string }[]; comments: DiagramComment[]; versionHistory: VersionHistoryEntry[]; @@ -75,6 +77,8 @@ export interface Folder { createdAt: string; updatedAt: string; deletedAt: string | null; + starred?: boolean; + starredAt?: string | null; } // Whether the app runs in read-only demo mode. Read here once so every backend (and the API routes @@ -252,6 +256,8 @@ export function normalizeDiagramDocument(raw: Partial): Diagram code: raw.code || "", type: raw.type || "flowchart", folderId: typeof raw.folderId === "string" ? raw.folderId : null, + starred: Boolean(raw.starred), + starredAt: typeof raw.starredAt === "string" ? raw.starredAt : null, subPages: Array.isArray(raw.subPages) ? raw.subPages : [], comments: normalizedComments, versionHistory: Array.isArray(raw.versionHistory) ? raw.versionHistory : [], @@ -266,6 +272,8 @@ export function normalizeFolder(raw: Partial): Folder { createdAt: raw.createdAt || new Date().toISOString(), updatedAt: raw.updatedAt || new Date().toISOString(), deletedAt: typeof raw.deletedAt === "string" ? raw.deletedAt : null, + starred: Boolean(raw.starred), + starredAt: typeof raw.starredAt === "string" ? raw.starredAt : null, }; } diff --git a/src/lib/diagrams/catalog.ts b/src/lib/diagrams/catalog.ts new file mode 100644 index 0000000..ca07f5f --- /dev/null +++ b/src/lib/diagrams/catalog.ts @@ -0,0 +1,181 @@ +import { DiagramRegistry } from "./registry"; + +export type DiagramCapability = "two-way" | "code-only"; + +export interface DiagramCatalogItem { + id: string; + label: string; + description: string; + capability: DiagramCapability; + defaultCode: string; + group: "scratch" | "template" | "code-only"; +} + +export interface DiagramTemplate { + id: string; + type: string; + name: string; + description: string; + code: string; +} + +const REGISTRY_TYPES = Object.values(DiagramRegistry).map((plugin) => ({ + id: plugin.id, + label: plugin.label, + defaultCode: plugin.defaultCode, +})); + +const TWO_WAY_TYPES = new Set(REGISTRY_TYPES.map((item) => item.id)); + +export const DIAGRAM_CATALOG: DiagramCatalogItem[] = [ + { + id: "blank", + label: "Blank", + description: "Start with an empty document and choose the Mermaid syntax later.", + capability: "code-only", + defaultCode: "", + group: "scratch", + }, + ...REGISTRY_TYPES.map( + (item): DiagramCatalogItem => ({ + ...item, + description: + item.id === "flowchart" + ? "Build node-and-edge flows with visual editing support." + : item.id === "sequence" + ? "Describe interactions between participants over time." + : item.id === "classDiagram" + ? "Model classes, members, and relationships." + : item.id === "erDiagram" + ? "Sketch entities, attributes, and relationships." + : item.id === "stateDiagram" + ? "Represent states, transitions, and composite states." + : "Create a hierarchical mindmap from Mermaid text.", + capability: TWO_WAY_TYPES.has(item.id) ? "two-way" : "code-only", + group: "template", + }), + ), + { + id: "gantt", + label: "Gantt Chart", + description: "Plan tasks across a dated project timeline.", + capability: "code-only", + defaultCode: `gantt + title Project Timeline + dateFormat YYYY-MM-DD + section Planning + Discovery :a1, 2026-01-01, 5d + Implementation :after a1, 10d`, + group: "code-only", + }, + { + id: "pie", + label: "Pie Chart", + description: "Compare positive numeric values as slices.", + capability: "code-only", + defaultCode: `pie title Diagram Types + "Flowcharts" : 45 + "Sequences" : 30 + "Other" : 25`, + group: "code-only", + }, + { + id: "timeline", + label: "Timeline", + description: "Show chronological events by period or section.", + capability: "code-only", + defaultCode: `timeline + title Product Milestones + 2026 Q1 : Research + 2026 Q2 : Build + 2026 Q3 : Launch`, + group: "code-only", + }, + { + id: "journey", + label: "User Journey", + description: "Map user tasks with satisfaction scores and actors.", + capability: "code-only", + defaultCode: `journey + title New user onboarding + section Discover + Find product: 4: User + Read docs: 3: User + section Adopt + Create first diagram: 5: User`, + group: "code-only", + }, + { + id: "gitGraph", + label: "Git Graph", + description: "Visualize commits, branches, checkouts, and merges.", + capability: "code-only", + defaultCode: `gitGraph + commit id: "init" + branch feature + checkout feature + commit id: "work" + checkout main + merge feature`, + group: "code-only", + }, + { + id: "requirementDiagram", + label: "Requirement Diagram", + description: "Connect requirements to elements using SysML-style relationships.", + capability: "code-only", + defaultCode: `requirementDiagram + +requirement req_login { + id: 1 + text: Users can sign in securely. + risk: high + verifymethod: test +} + +element auth_service { + type: service +} + +auth_service - satisfies -> req_login`, + group: "code-only", + }, + { + id: "C4Context", + label: "C4 Context", + description: "Document people, systems, and relationships at system-context level.", + capability: "code-only", + defaultCode: `C4Context + title System Context + Person(user, "User", "Creates diagrams") + System(livemaid, "LiveMaid", "Mermaid diagram workspace") + Rel(user, livemaid, "Creates and edits diagrams")`, + group: "code-only", + }, +]; + +export const DIAGRAM_TEMPLATES: DiagramTemplate[] = DIAGRAM_CATALOG.filter( + (item) => item.group === "template", +).map((item) => ({ + id: `${item.id}-starter`, + type: item.id, + name: `${item.label} starter`, + description: item.description, + code: item.defaultCode, +})); + +export function getDiagramCatalogItem(type: string | null | undefined): DiagramCatalogItem { + return DIAGRAM_CATALOG.find((item) => item.id === type) ?? DIAGRAM_CATALOG[0]; +} + +export function getDiagramTemplate(templateId: string | null | undefined): DiagramTemplate | null { + return DIAGRAM_TEMPLATES.find((template) => template.id === templateId) ?? null; +} + +export function getDiagramCapability(type: string | null | undefined): DiagramCapability { + return getDiagramCatalogItem(type).capability; +} + +export function isCreatableDiagramType(type: string): boolean { + return DIAGRAM_CATALOG.some((item) => item.id === type); +} diff --git a/src/lib/diagrams/utils.ts b/src/lib/diagrams/utils.ts index 9501dcd..7fa925d 100644 --- a/src/lib/diagrams/utils.ts +++ b/src/lib/diagrams/utils.ts @@ -63,6 +63,8 @@ export function updateMermaidTheme(code: string, newTheme: string): string { } export function determineDiagramType(sourceCode: string): string { + if (sourceCode.trim().length === 0) return "blank"; + const lines = sourceCode.split("\n"); let inConfig = false; for (const line of lines) { @@ -75,11 +77,15 @@ export function determineDiagramType(sourceCode: string): string { if (trimmed.startsWith("flowchart") || trimmed.startsWith("graph")) return "flowchart"; if (trimmed.startsWith("sequenceDiagram")) return "sequence"; + if (trimmed.startsWith("gitGraph")) return "gitGraph"; + if (trimmed.startsWith("requirementDiagram")) return "requirementDiagram"; + if (/^C4(?:Context|Container|Component|Dynamic|Deployment)\b/.test(trimmed)) + return trimmed.split(/\s+/)[0]; - const match = trimmed.match(/^([a-zA-Z]+)/); + const match = trimmed.match(/^([a-zA-Z][a-zA-Z0-9-]*)/); if (match) return match[1]; } - return "flowchart"; + return "blank"; } /** @@ -90,6 +96,7 @@ export function determineDiagramType(sourceCode: string): string { export function diagramTypeLabel(type: string): string { const KNOWN: Record = { flowchart: "Flowchart", + blank: "Blank Diagram", graph: "Flowchart", sequence: "Sequence Diagram", sequenceDiagram: "Sequence Diagram", diff --git a/src/test/dashboard-header.spec.ts b/src/test/dashboard-header.spec.ts new file mode 100644 index 0000000..56bfb13 --- /dev/null +++ b/src/test/dashboard-header.spec.ts @@ -0,0 +1,211 @@ +import { expect, test, type Page } from "@playwright/test"; + +const now = "2026-07-08T12:00:00.000Z"; + +const diagrams = [ + { + id: "diagram-alpha", + name: "Alpha Flow", + createdAt: now, + updatedAt: "2026-07-08T12:03:00.000Z", + deletedAt: null, + type: "flowchart", + folderId: null, + starred: true, + starredAt: "2026-07-08T12:04:00.000Z", + subPages: [], + comments: [], + versionHistory: [], + }, + { + id: "diagram-beta", + name: "Beta Map", + createdAt: "2026-07-08T11:00:00.000Z", + updatedAt: "2026-07-08T11:05:00.000Z", + deletedAt: null, + type: "flowchart", + folderId: "folder-design", + starred: false, + starredAt: null, + subPages: [], + comments: [], + versionHistory: [], + }, + { + id: "diagram-gamma", + name: "Gamma Spec", + createdAt: "2026-07-08T10:00:00.000Z", + updatedAt: "2026-07-08T10:05:00.000Z", + deletedAt: null, + type: "flowchart", + folderId: null, + starred: false, + starredAt: null, + subPages: [], + comments: [], + versionHistory: [], + }, +]; + +const folders = [ + { + id: "folder-design", + name: "Design Systems", + parentId: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + starred: false, + starredAt: null, + }, +]; + +async function mockDashboardData(page: Page) { + await page.route("**/api/diagrams", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: diagrams, total: diagrams.length }), + }); + }); + + await page.route("**/api/folders", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(folders), + }); + }); +} + +test.describe("dashboard header", () => { + test.beforeEach(async ({ page }) => { + await mockDashboardData(page); + }); + + test("shows active dashboard context and grouped desktop controls", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Your Diagrams" })).toBeVisible(); + await expect(page.getByRole("button", { name: /Workspace/ })).toBeVisible(); + await expect(page.getByLabel("Search dashboard")).toHaveAttribute( + "placeholder", + "Search workspace", + ); + await expect(page.getByRole("button", { name: "Sort diagrams" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Grid view" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(page.getByRole("button", { name: "New Folder", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "New Diagram" })).toHaveClass(/bg-\[#7a3dff\]/); + + await page.getByRole("button", { name: "All Files" }).click(); + await expect(page.getByRole("heading", { name: "All Files" })).toBeVisible(); + await expect(page.getByLabel("Search dashboard")).toHaveAttribute( + "placeholder", + "Search all files", + ); + + await page.getByRole("button", { name: "Recent" }).click(); + await expect(page.getByRole("heading", { name: "Recent Diagrams" })).toBeVisible(); + await expect(page.getByLabel("Search dashboard")).toHaveAttribute( + "placeholder", + "Search recent diagrams", + ); + + await page.getByRole("button", { name: "Starred" }).click(); + await expect(page.getByRole("heading", { name: "Starred Items" })).toBeVisible(); + await expect(page.getByLabel("Search dashboard")).toHaveAttribute( + "placeholder", + "Search starred items", + ); + }); + + test("updates title and breadcrumb when a folder is selected", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + + await page.getByText("Design Systems").last().click(); + + await expect(page.getByRole("heading", { name: "Design Systems" })).toBeVisible(); + await expect(page.getByRole("button", { name: /Workspace/ })).toBeVisible(); + await expect(page.getByRole("button", { name: "Design Systems" }).first()).toBeVisible(); + await expect(page.getByLabel("Search dashboard")).toHaveAttribute( + "placeholder", + "Search in Design Systems", + ); + }); + + test("keeps search, view toggle, and creation actions usable on narrow screens", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Your Diagrams" })).toBeVisible(); + await page.getByLabel("Search dashboard").fill("Alpha"); + await expect(page.locator("[data-slot='card-title']", { hasText: "Alpha Flow" })).toBeVisible(); + await expect(page.locator("[data-slot='card-title']", { hasText: "Beta Map" })).toHaveCount(0); + await expect(page.getByText('1 result for "Alpha"')).toBeVisible(); + + await page.getByRole("button", { name: "List view" }).click(); + await expect(page.getByRole("button", { name: "List view" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByRole("menuitem", { name: "New Folder" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "New Diagram" })).toBeVisible(); + }); + + test("filters folders and diagrams within the active search scope", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + + await page.getByLabel("Search dashboard").fill("Design"); + await expect(page.getByText('1 result for "Design"')).toBeVisible(); + await expect(page.getByText("Folders (1)")).toBeVisible(); + await expect(page.getByText("Design Systems").last()).toBeVisible(); + await expect(page.locator("[data-slot='card-title']", { hasText: "Alpha Flow" })).toHaveCount( + 0, + ); + + await page.getByRole("button", { name: "Clear search" }).click(); + await page.getByText("Design Systems").last().click(); + await expect(page.getByRole("heading", { name: "Design Systems" })).toBeVisible(); + await expect(page.locator("[data-slot='card-title']", { hasText: "Beta Map" })).toBeVisible(); + + await page.getByLabel("Search dashboard").fill("Alpha"); + await expect(page.getByText('0 results for "Alpha"')).toBeVisible(); + await expect(page.getByText('No results found for "Alpha"')).toBeVisible(); + }); + + test("supports keyboard focus and clearing shortcuts", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + + await page.keyboard.press("/"); + await expect(page.getByLabel("Search dashboard")).toBeFocused(); + + await page.keyboard.type("Gamma"); + await expect(page.getByText('1 result for "Gamma"')).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.getByLabel("Search dashboard")).toHaveValue(""); + await expect(page.getByText(/result for/)).toHaveCount(0); + + await page.keyboard.press("Escape"); + await expect(page.getByLabel("Search dashboard")).not.toBeFocused(); + }); +}); diff --git a/src/test/diagram-catalog.test.ts b/src/test/diagram-catalog.test.ts new file mode 100644 index 0000000..fcbb68a --- /dev/null +++ b/src/test/diagram-catalog.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + DIAGRAM_CATALOG, + DIAGRAM_TEMPLATES, + getDiagramCapability, + getDiagramTemplate, + isCreatableDiagramType, +} from "@/lib/diagrams/catalog"; +import { determineDiagramType } from "@/lib/diagrams/utils"; +import { normalizeDiagramDocument, normalizeFolder } from "@/lib/api/storageTypes"; + +describe("diagram catalogue", () => { + it("defines labels and capabilities for every creatable type", () => { + for (const item of DIAGRAM_CATALOG) { + expect(item.label).toBeTruthy(); + expect(["two-way", "code-only"]).toContain(item.capability); + expect(isCreatableDiagramType(item.id)).toBe(true); + } + }); + + it("keeps templates pointed at valid diagram types", () => { + for (const template of DIAGRAM_TEMPLATES) { + expect(getDiagramTemplate(template.id)).toEqual(template); + expect(isCreatableDiagramType(template.type)).toBe(true); + expect(template.code.trim()).not.toEqual(""); + } + }); + + it("labels known editor-backed types as two-way editable", () => { + expect(getDiagramCapability("flowchart")).toBe("two-way"); + expect(getDiagramCapability("sequence")).toBe("two-way"); + expect(getDiagramCapability("classDiagram")).toBe("two-way"); + expect(getDiagramCapability("erDiagram")).toBe("two-way"); + expect(getDiagramCapability("stateDiagram")).toBe("two-way"); + }); +}); + +describe("diagram type detection", () => { + it("treats empty code as blank", () => { + expect(determineDiagramType("")).toBe("blank"); + expect(determineDiagramType("\n \n%% comment only")).toBe("blank"); + }); + + it("detects code-only diagram families", () => { + expect(determineDiagramType("gitGraph\n commit")).toBe("gitGraph"); + expect(determineDiagramType("requirementDiagram\nrequirement r {\n}")).toBe( + "requirementDiagram", + ); + expect(determineDiagramType('C4Context\nPerson(user, "User")')).toBe("C4Context"); + }); +}); + +describe("starred metadata normalization", () => { + it("defaults old documents and folders to unstarred", () => { + expect(normalizeDiagramDocument({ id: "d1" }).starred).toBe(false); + expect(normalizeDiagramDocument({ id: "d1" }).starredAt).toBeNull(); + expect(normalizeFolder({ id: "f1" }).starred).toBe(false); + expect(normalizeFolder({ id: "f1" }).starredAt).toBeNull(); + }); + + it("preserves starred document and folder metadata", () => { + const starredAt = "2026-01-01T00:00:00.000Z"; + expect(normalizeDiagramDocument({ id: "d1", starred: true, starredAt }).starredAt).toBe( + starredAt, + ); + expect(normalizeFolder({ id: "f1", starred: true, starredAt }).starredAt).toBe(starredAt); + }); +});