diff --git a/Caddyfile b/Caddyfile index af2a42a..5cd8f6d 100644 --- a/Caddyfile +++ b/Caddyfile @@ -6,25 +6,19 @@ :80 { encode zstd gzip - @mcp path /mcp - handle @mcp { + handle /mcp { reverse_proxy 127.0.0.1:3001 } - @api path /api/* - handle @api { - reverse_proxy 127.0.0.1:3000 - } - - @root path / - handle @root { + @app path / /api/* + handle @app { reverse_proxy 127.0.0.1:3000 } handle { root * /srv/public - @html path /index.html /d/* + @html path /index.html /d/* /p/* header @html Cache-Control "no-cache" @assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$ diff --git a/src/client/App.tsx b/src/client/App.tsx index fc4dac4..a40b40f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,10 +1,29 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar"; import { EditorCanvas } from "./components/EditorCanvas"; +import { PublicViewer } from "./components/PublicViewer"; +import { usePublicDrawing } from "./hooks/usePublicDrawing"; import { useDrawingSession } from "./hooks/useDrawingSession"; import { useThemeTokenSync } from "./hooks/useThemeTokenSync"; -export function App() { +type AppRoute = + | { type: "private" } + | { type: "public"; slug: string }; + +function routeFromPath(pathname = window.location.pathname): AppRoute { + if (!pathname.startsWith("/p/")) { + return { type: "private" }; + } + + const slug = pathname.slice(3); + if (slug.length === 0 || slug.includes("/")) { + return { type: "private" }; + } + + return { type: "public", slug: decodeURIComponent(slug) }; +} + +function PrivateApp() { const [isSidebarOpen, setIsSidebarOpen] = useState(false); const appShellRef = useRef(null); const scheduleThemeTokenSync = useThemeTokenSync(appShellRef); @@ -17,12 +36,18 @@ export function App() { error, toastMessage, editorReloadNonce, + publication, + publicationSlug, + publicationBusy, setActiveTitle, submitTitle, handleSceneChange, selectDrawing, createDrawing, deleteDrawing, + setPublicationSlug, + publishPublication, + disablePublication, } = useDrawingSession(); useEffect(() => { @@ -98,13 +123,35 @@ export function App() { drawings={drawings} activeId={activeId} activeTitle={activeTitle} + publication={publication} + publicationSlug={publicationSlug} + publicationBusy={publicationBusy} onClose={closeSidebar} onCreate={handleCreateDrawing} onSelect={handleSelectDrawing} onDelete={(drawingId) => void deleteDrawing(drawingId)} onTitleChange={setActiveTitle} onTitleSubmit={() => void submitTitle()} + onPublicationSlugChange={setPublicationSlug} + onPublish={() => void publishPublication()} + onDisablePublication={() => void disablePublication()} /> ); } + +function PublicApp({ slug }: { slug: string }) { + const { drawing, loading, error } = usePublicDrawing(slug); + + return ; +} + +export function App() { + const route = routeFromPath(); + + if (route.type === "public") { + return ; + } + + return ; +} diff --git a/src/client/components/DrawingSidebar.tsx b/src/client/components/DrawingSidebar.tsx index bfab44f..e12ac5f 100644 --- a/src/client/components/DrawingSidebar.tsx +++ b/src/client/components/DrawingSidebar.tsx @@ -1,16 +1,22 @@ -import { type DrawingMeta } from "../../core/shared"; +import { type DrawingMeta, type DrawingPublication } from "../../core/shared"; type DrawingSidebarProps = { open: boolean; drawings: DrawingMeta[]; activeId: string | null; activeTitle: string; + publication: DrawingPublication; + publicationSlug: string; + publicationBusy: boolean; onClose: () => void; onCreate: () => void; onSelect: (drawingId: string) => void; onDelete: (drawingId: string) => void; onTitleChange: (title: string) => void; onTitleSubmit: () => void; + onPublicationSlugChange: (slug: string) => void; + onPublish: () => void; + onDisablePublication: () => void; }; function DrawerIcon() { @@ -42,13 +48,22 @@ export function DrawingSidebar({ drawings, activeId, activeTitle, + publication, + publicationSlug, + publicationBusy, onClose, onCreate, onSelect, onDelete, onTitleChange, onTitleSubmit, + onPublicationSlugChange, + onPublish, + onDisablePublication, }: DrawingSidebarProps) { + const publicPath = publication.enabled ? `/p/${publication.slug}` : null; + const publicationStatus = publication.enabled ? `Published at ${publicPath}` : "Not published"; + return ( <>
{drawing.id === activeId ? ( -
- onTitleChange(event.target.value)} - onBlur={onTitleSubmit} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.currentTarget.blur(); - } - }} - /> +
+
+ onTitleChange(event.target.value)} + onBlur={onTitleSubmit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> + +
+
+ + onPublicationSlugChange(event.target.value)} + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + /> +
+ + +
+
+ {publication.enabled ? ( + + {publicationStatus} + + ) : ( + {publicationStatus} + )} +
+
) : ( )} - + {drawing.id !== activeId ? ( + + ) : null}
))}
diff --git a/src/client/components/EditorCanvas.tsx b/src/client/components/EditorCanvas.tsx index 156db3e..c5f4844 100644 --- a/src/client/components/EditorCanvas.tsx +++ b/src/client/components/EditorCanvas.tsx @@ -1,11 +1,11 @@ import { memo } from "react"; -import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css"; import { Excalidraw } from "@excalidraw/excalidraw"; import type { AppState, BinaryFiles, ExcalidrawInitialDataState, } from "@excalidraw/excalidraw/types"; +import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css"; import { type ScenePayload } from "../../core/shared"; type EditorCanvasProps = { diff --git a/src/client/components/PublicViewer.tsx b/src/client/components/PublicViewer.tsx new file mode 100644 index 0000000..d7e44a6 --- /dev/null +++ b/src/client/components/PublicViewer.tsx @@ -0,0 +1,53 @@ +import { memo } from "react"; +import { Excalidraw } from "@excalidraw/excalidraw"; +import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types"; +import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css"; +import { type PublicDrawing } from "../../core/shared"; + +type PublicViewerProps = { + drawing: PublicDrawing | null; + loading: boolean; + error: string | null; +}; + +function drawingToInitialData( + drawing: PublicDrawing, +): ExcalidrawInitialDataState { + return drawing as ExcalidrawInitialDataState; +} + +const viewerUiOptions = { + canvasActions: { + changeViewBackgroundColor: false, + clearCanvas: false, + export: false, + loadScene: false, + saveAsImage: false, + saveToActiveFile: false, + toggleTheme: false, + }, + tools: { + image: false, + }, +} as const; + +export const PublicViewer = memo(function PublicViewer({ + drawing, + loading, + error, +}: PublicViewerProps) { + if (loading || !drawing) { + return
{error ?? "Loading..."}
; + } + + return ( +
+ +
+ ); +}); diff --git a/src/client/hooks/useDrawingSession.ts b/src/client/hooks/useDrawingSession.ts index 5fa4968..86f8c91 100644 --- a/src/client/hooks/useDrawingSession.ts +++ b/src/client/hooks/useDrawingSession.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "../../core/shared"; +import { DEFAULT_TITLE, type DrawingMeta, type DrawingPublication, type ScenePayload } from "../../core/shared"; type DrawingResponse = DrawingMeta & ScenePayload; +type PublicationResponse = DrawingPublication; type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta }; type UpdateResponse = { ok: true; drawing: DrawingMeta }; @@ -70,10 +71,14 @@ export function useDrawingSession() { const [error, setError] = useState(null); const [toastMessage, setToastMessage] = useState(null); const [editorReloadNonce, setEditorReloadNonce] = useState(0); + const [publication, setPublication] = useState({ enabled: false }); + const [publicationSlug, setPublicationSlugState] = useState(""); + const [publicationBusy, setPublicationBusy] = useState(false); const currentIdRef = useRef(activeId); const currentTitleRef = useRef(activeTitle); const latestSceneRef = useRef(null); + const publicationEnabledRef = useRef(false); const ignoreChangeRef = useRef(false); const saveTimeoutRef = useRef(null); const toastTimeoutRef = useRef(null); @@ -106,6 +111,18 @@ export function useDrawingSession() { return list; }, []); + const applyPublication = useCallback((nextPublication: DrawingPublication) => { + publicationEnabledRef.current = nextPublication.enabled; + setPublication(nextPublication); + + if (nextPublication.enabled) { + setPublicationSlugState(nextPublication.slug); + return; + } + + setPublicationSlugState(""); + }, []); + const loadDrawing = useCallback( async (drawingId: string) => { const version = ++loadVersionRef.current; @@ -113,9 +130,10 @@ export function useDrawingSession() { setError(null); try { - const [list, drawing] = await Promise.all([ + const [list, drawing, nextPublication] = await Promise.all([ requestJson("/api/drawings", { method: "GET" }), requestJson(`/api/drawings/${drawingId}`, { method: "GET" }), + requestJson(`/api/drawings/${drawingId}/publication`, { method: "GET" }), ]); if (version !== loadVersionRef.current) { @@ -134,6 +152,7 @@ export function useDrawingSession() { setActiveId(drawing.id); setActiveTitleState(drawing.title); setScene(nextScene); + applyPublication(nextPublication); setEditorReloadNonce((current) => current + 1); } catch (loadError) { if (version !== loadVersionRef.current) { @@ -147,7 +166,7 @@ export function useDrawingSession() { } } }, - [], + [applyPublication], ); const saveScene = useCallback( @@ -350,6 +369,72 @@ export function useDrawingSession() { [navigateToDrawing], ); + const setPublicationSlug = useCallback((slug: string) => { + setPublicationSlugState(slug); + }, []); + + const publishPublication = useCallback(async () => { + const drawingId = currentIdRef.current; + if (!drawingId) { + return; + } + + setPublicationBusy(true); + setError(null); + showToast("Publishing..."); + + try { + await flushPendingSave(); + + const currentDrawingId = currentIdRef.current; + if (!currentDrawingId) { + return; + } + + const body = await requestJson(`/api/drawings/${currentDrawingId}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: publicationSlug, + }), + }); + + applyPublication(body); + showToast("Published", 2000); + } catch (publishError) { + setError(publishError instanceof Error ? publishError.message : "Publish failed"); + showToast("Publish failed", 2000); + } finally { + setPublicationBusy(false); + } + }, [applyPublication, flushPendingSave, publicationSlug, showToast]); + + const disablePublication = useCallback(async () => { + const drawingId = currentIdRef.current; + if (!drawingId) { + return; + } + + setPublicationBusy(true); + setError(null); + showToast("Disabling..."); + + try { + const body = await requestJson(`/api/drawings/${drawingId}/publication`, { + method: "PUT", + body: JSON.stringify({ enabled: false }), + }); + + applyPublication(body); + showToast("Publication disabled", 2000); + } catch (disableError) { + setError(disableError instanceof Error ? disableError.message : "Disable failed"); + showToast("Disable failed", 2000); + } finally { + setPublicationBusy(false); + } + }, [applyPublication, showToast]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { @@ -441,11 +526,17 @@ export function useDrawingSession() { error, toastMessage, editorReloadNonce, + publication, + publicationSlug, + publicationBusy, setActiveTitle, submitTitle, handleSceneChange, selectDrawing, createDrawing, deleteDrawing, + setPublicationSlug, + publishPublication, + disablePublication, }; } diff --git a/src/client/hooks/usePublicDrawing.ts b/src/client/hooks/usePublicDrawing.ts new file mode 100644 index 0000000..d25db4e --- /dev/null +++ b/src/client/hooks/usePublicDrawing.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { type PublicDrawing } from "../../core/shared"; + +type PublicDrawingState = { + drawing: PublicDrawing | null; + loading: boolean; + error: string | null; +}; + +class RequestError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +async function requestPublicDrawing(slug: string): Promise { + const response = await fetch(`/api/public/drawings/${encodeURIComponent(slug)}`); + const body = (await response.json()) as PublicDrawing & { error?: string }; + + if (!response.ok) { + throw new RequestError(response.status, body.error ?? `Request failed: ${response.status}`); + } + + return body; +} + +export function usePublicDrawing(slug: string): PublicDrawingState { + const [drawing, setDrawing] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + void requestPublicDrawing(slug) + .then((nextDrawing) => { + if (cancelled) { + return; + } + + setDrawing(nextDrawing); + document.title = nextDrawing.title; + }) + .catch((loadError: unknown) => { + if (cancelled) { + return; + } + + setDrawing(null); + setError(loadError instanceof Error ? loadError.message : "Failed to load drawing"); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [slug]); + + return { + drawing, + loading, + error, + }; +} diff --git a/src/client/styles.css b/src/client/styles.css index 0105462..edf8e21 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -134,6 +134,22 @@ input { font-size: var(--app-sidebar-font-size); } +.drawing-link-active { + display: grid; + gap: 10px; +} + +.drawing-item-header { + display: flex; + align-items: center; + gap: 8px; +} + +.drawing-delete-button { + flex: 0 0 32px; + margin-left: auto; +} + .drawing-title, .title-input { display: block; @@ -152,6 +168,44 @@ input { padding: 6px 8px; } +.publication-panel { + display: grid; + gap: 8px; + padding: 8px 0 4px; +} + +.publication-label, +.publication-status { + color: color-mix(in srgb, var(--app-text-color) 75%, white); + font-size: 0.75rem; +} + +.publication-input { + font-family: + ui-monospace, + SFMono-Regular, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + "Liberation Mono", + monospace; +} + +.publication-actions { + display: flex; + gap: 8px; +} + +.publication-link { + color: inherit; + text-decoration: none; +} + +.publication-link:hover { + text-decoration: underline; +} + .editor-shell { position: relative; height: 100%; @@ -270,6 +324,7 @@ input { } .editor-frame, +.public-viewer-shell, .editor-loading { height: 100%; } diff --git a/src/core/publication.ts b/src/core/publication.ts new file mode 100644 index 0000000..324f11f --- /dev/null +++ b/src/core/publication.ts @@ -0,0 +1,25 @@ +import { HttpError } from "./scene"; + +const RESERVED_SLUGS = new Set(["api", "d", "p", "mcp"]); + +export function normalizePublicationSlug(input: unknown): string { + if (typeof input !== "string") { + throw new HttpError(400, "slug must be a string"); + } + + const normalized = input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + if (normalized.length === 0) { + throw new HttpError(400, "slug is required"); + } + + if (RESERVED_SLUGS.has(normalized)) { + throw new HttpError(400, "slug is reserved"); + } + + return normalized; +} diff --git a/src/core/shared.ts b/src/core/shared.ts index e69c4fd..10bb531 100644 --- a/src/core/shared.ts +++ b/src/core/shared.ts @@ -4,6 +4,19 @@ export type ScenePayload = { files: Record; }; +export type DrawingPublication = + | { + enabled: false; + } + | { + enabled: true; + slug: string; + }; + +export type PublicDrawing = { + title: string; +} & ScenePayload; + export type DrawingMeta = { id: string; title: string; diff --git a/src/server/api.test.ts b/src/server/api.test.ts index 2f12b6e..485dd66 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -205,4 +205,210 @@ describe("api", () => { expect(stored.elements).toEqual([{ id: "server" }]); cleanup(); }); + + test("returns disabled and enabled publication shapes", async () => { + const { api, cleanup } = withApi(); + const created = await api.createDrawing().json(); + + const disabled = await api.getDrawingPublication( + Object.assign(new Request(`http://local/api/drawings/${created.id}/publication`), { params: { id: created.id } }), + ); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toEqual({ enabled: false }); + + const publish = await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${created.id}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: "Flow Chart", + }), + }), + { params: { id: created.id } }, + ), + ); + + expect(publish.status).toBe(200); + expect(await publish.json()).toEqual({ + enabled: true, + slug: "flow-chart", + }); + + const enabled = await api.getDrawingPublication( + Object.assign(new Request(`http://local/api/drawings/${created.id}/publication`), { params: { id: created.id } }), + ); + expect(await enabled.json()).toEqual({ + enabled: true, + slug: "flow-chart", + }); + cleanup(); + }); + + test("publishes, renames, and disables publications", async () => { + const { api, cleanup } = withApi(); + const created = await api.createDrawing().json(); + + await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${created.id}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: "first", + }), + }), + { params: { id: created.id } }, + ), + ); + + const renamed = await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${created.id}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: "second", + }), + }), + { params: { id: created.id } }, + ), + ); + + expect(renamed.status).toBe(200); + expect(await renamed.json()).toEqual({ + enabled: true, + slug: "second", + }); + + const disable = await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${created.id}/publication`, { + method: "PUT", + body: JSON.stringify({ enabled: false }), + }), + { params: { id: created.id } }, + ), + ); + + expect(disable.status).toBe(200); + expect(await disable.json()).toEqual({ enabled: false }); + cleanup(); + }); + + test("returns 400 for invalid publication slugs", async () => { + const { api, cleanup } = withApi(); + const created = await api.createDrawing().json(); + + const response = await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${created.id}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: "API", + }), + }), + { params: { id: created.id } }, + ), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + ok: false, + error: "slug is reserved", + }); + cleanup(); + }); + + test("returns 409 for publication slug collisions", async () => { + const { api, cleanup } = withApi(); + const first = await api.createDrawing().json(); + const second = await api.createDrawing().json(); + + await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${first.id}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: "shared", + }), + }), + { params: { id: first.id } }, + ), + ); + + const response = await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${second.id}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: "shared", + }), + }), + { params: { id: second.id } }, + ), + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + ok: false, + error: "Published slug already exists", + }); + cleanup(); + }); + + test("returns public scene data for enabled slugs and 404 otherwise", async () => { + const { api, cleanup } = withApi(); + const created = await api.createDrawing().json(); + + await api.updateDrawing( + Object.assign( + new Request(`http://local/api/drawings/${created.id}`, { + method: "PUT", + body: JSON.stringify({ + title: "Public Flow", + elements: [{ id: "shape" }], + appState: { theme: "dark" }, + files: {}, + expectedRevision: 0, + }), + }), + { params: { id: created.id } }, + ), + ); + + await api.updateDrawingPublication( + Object.assign( + new Request(`http://local/api/drawings/${created.id}/publication`, { + method: "PUT", + body: JSON.stringify({ + enabled: true, + slug: "public-flow", + }), + }), + { params: { id: created.id } }, + ), + ); + + const enabled = await api.getPublicDrawing( + Object.assign(new Request("http://local/api/public/drawings/public-flow"), { params: { slug: "public-flow" } }), + ); + + expect(enabled.status).toBe(200); + expect(await enabled.json()).toEqual({ + title: "Public Flow", + elements: [{ id: "shape" }], + appState: { theme: "dark" }, + files: {}, + }); + + const missing = await api.getPublicDrawing( + Object.assign(new Request("http://local/api/public/drawings/missing"), { params: { slug: "missing" } }), + ); + expect(missing.status).toBe(404); + cleanup(); + }); }); diff --git a/src/server/api.ts b/src/server/api.ts index 35a6768..b5b50df 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -1,5 +1,6 @@ import { type DrawingStore } from "./db"; -import { type DrawingMeta } from "../core/shared"; +import { normalizePublicationSlug } from "../core/publication"; +import { type DrawingMeta, type DrawingPublication } from "../core/shared"; import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene"; type RouteRequest = Request & { @@ -46,6 +47,10 @@ function parseExpectedRevision(raw: Record): number | undefined return value; } +function jsonPublication(publication: DrawingPublication): Response { + return json(publication); +} + export function createApi(store: DrawingStore) { return { health() { @@ -85,6 +90,24 @@ export function createApi(store: DrawingStore) { } }, + getPublicDrawing(request: RouteRequest) { + try { + const slug = request.params?.slug; + if (!slug) { + throw new HttpError(400, "Missing published slug"); + } + + const drawing = store.getPublicDrawing(slug); + if (!drawing) { + return json({ ok: false, error: "Drawing not found" }, { status: 404 }); + } + + return json(drawing); + } catch (error) { + return errorResponse(error); + } + }, + async updateDrawing(request: RouteRequest) { try { const id = request.params?.id; @@ -150,5 +173,67 @@ export function createApi(store: DrawingStore) { return json({ ok: true, nextId: next?.id ?? null }); }, + + getDrawingPublication(request: RouteRequest) { + try { + const id = request.params?.id; + if (!id) { + throw new HttpError(400, "Missing drawing id"); + } + + const publication = store.getDrawingPublication(id); + if (!publication) { + return json({ ok: false, error: "Drawing not found" }, { status: 404 }); + } + + return jsonPublication(publication); + } catch (error) { + return errorResponse(error); + } + }, + + async updateDrawingPublication(request: RouteRequest) { + try { + const id = request.params?.id; + if (!id) { + throw new HttpError(400, "Missing drawing id"); + } + + const raw = parseJsonBody>(await request.text()); + if (raw.enabled === false) { + const result = store.disableDrawingPublication(id); + if (!result.ok) { + return json({ ok: false, error: "Drawing not found" }, { status: 404 }); + } + + return jsonPublication(result.publication); + } + + if (raw.enabled !== true) { + throw new HttpError(400, "enabled must be a boolean"); + } + + const drawing = store.getDrawing(id); + if (!drawing) { + return json({ ok: false, error: "Drawing not found" }, { status: 404 }); + } + + const result = store.publishDrawing(id, { + slug: normalizePublicationSlug(raw.slug), + }); + + if (!result.ok && result.reason === "not_found") { + return json({ ok: false, error: "Drawing not found" }, { status: 404 }); + } + + if (!result.ok && result.reason === "slug_conflict") { + return json({ ok: false, error: "Published slug already exists" }, { status: 409 }); + } + + return jsonPublication(result.publication); + } catch (error) { + return errorResponse(error); + } + }, }; } diff --git a/src/server/db.test.ts b/src/server/db.test.ts index c67a1a5..e7ee3ab 100644 --- a/src/server/db.test.ts +++ b/src/server/db.test.ts @@ -135,4 +135,98 @@ describe("drawing store", () => { expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed"); expect(renamed.ok ? renamed.drawing.revision : null).toBe(2); }); + + test("persists publication fields and exposes public drawings by slug", () => { + const store = createTempStore(); + const created = store.createDrawing("Flow"); + store.updateDrawing(created.id, { + scene: { + elements: [{ id: "one" }], + appState: { theme: "dark" }, + files: {}, + }, + }); + + const published = store.publishDrawing(created.id, { slug: "flow" }); + + expect(published.ok).toBe(true); + expect(store.getDrawingPublication(created.id)).toEqual({ + enabled: true, + slug: "flow", + }); + expect(store.getPublicDrawing("flow")).toEqual({ + title: "Flow", + elements: [{ id: "one" }], + appState: { theme: "dark" }, + files: {}, + }); + }); + + test("enforces unique active publication slugs", () => { + const store = createTempStore(); + const first = store.createDrawing("First"); + const second = store.createDrawing("Second"); + + expect( + store.publishDrawing(first.id, { + slug: "shared", + }), + ).toEqual({ + ok: true, + publication: { + enabled: true, + slug: "shared", + }, + }); + + expect( + store.publishDrawing(second.id, { + slug: "shared", + }), + ).toEqual({ + ok: false, + reason: "slug_conflict", + }); + + expect(store.disableDrawingPublication(first.id)).toEqual({ + ok: true, + publication: { enabled: false }, + }); + + expect( + store.publishDrawing(second.id, { + slug: "shared", + }), + ).toEqual({ + ok: true, + publication: { + enabled: true, + slug: "shared", + }, + }); + }); + + test("clears public fields on disable", () => { + const store = createTempStore(); + const created = store.createDrawing(); + + expect( + store.publishDrawing(created.id, { + slug: "note", + }), + ).toEqual({ + ok: true, + publication: { + enabled: true, + slug: "note", + }, + }); + + expect(store.disableDrawingPublication(created.id)).toEqual({ + ok: true, + publication: { enabled: false }, + }); + expect(store.getDrawingPublication(created.id)).toEqual({ enabled: false }); + expect(store.getPublicDrawing("note")).toBeNull(); + }); }); diff --git a/src/server/db.ts b/src/server/db.ts index a921ae8..6eba633 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"; import { randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "../core/shared"; +import { DEFAULT_TITLE, type DrawingMeta, type DrawingPublication, type DrawingRecord, type ScenePayload } from "../core/shared"; import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene"; type DrawingRow = { @@ -16,11 +16,26 @@ type DrawingRow = { type DrawingMetaRow = Omit; +type DrawingPublicationRow = { + publicEnabled: number; + publicSlug: string | null; +}; + +type PublicDrawingRow = { + title: string; + data: string; +}; + export type DrawingUpdateResult = | { ok: true; drawing: DrawingRecord } | { ok: false; reason: "not_found" } | { ok: false; reason: "conflict"; drawing: DrawingRecord }; +export type DrawingPublicationUpdateResult = + | { ok: true; publication: DrawingPublication } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "slug_conflict" }; + export type DrawingStore = ReturnType; function createId(): string { @@ -54,6 +69,40 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null { }; } +function readPublication(row: DrawingPublicationRow | null | undefined): DrawingPublication | null { + if (!row) { + return null; + } + + if (row.publicEnabled !== 1 || !row.publicSlug) { + return { enabled: false }; + } + + return { + enabled: true, + slug: row.publicSlug, + }; +} + +function readPublicDrawing(row: PublicDrawingRow | null | undefined) { + if (!row) { + return null; + } + + let scene: ScenePayload; + try { + scene = normalizeScene(JSON.parse(row.data)); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown scene parsing error"; + throw new Error(`Invalid stored public scene: ${message}`); + } + + return { + title: row.title, + ...scene, + }; +} + function readRow(row: DrawingRow | null | undefined): DrawingRecord | null { if (!row) { return null; @@ -73,6 +122,25 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null { }; } +function ensurePublicationColumns(db: Database) { + const columns = db + .query("PRAGMA table_info(drawings)") + .all() as Array<{ name: string }>; + const names = new Set(columns.map((column) => column.name)); + + if (!names.has("public_enabled")) { + db.exec("ALTER TABLE drawings ADD COLUMN public_enabled INTEGER NOT NULL DEFAULT 0"); + } + + if (!names.has("public_slug")) { + db.exec("ALTER TABLE drawings ADD COLUMN public_slug TEXT"); + } +} + +function isSlugConflictError(error: unknown): boolean { + return error instanceof Error && error.message.includes("UNIQUE constraint failed: drawings.public_slug"); +} + export function createDrawingStore(databasePath = resolveDatabasePath()) { mkdirSync(dirname(databasePath), { recursive: true }); @@ -86,13 +154,22 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) { data TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - revision INTEGER NOT NULL DEFAULT 0 + revision INTEGER NOT NULL DEFAULT 0, + public_enabled INTEGER NOT NULL DEFAULT 0, + public_slug TEXT ); CREATE INDEX IF NOT EXISTS drawings_updated_at_idx ON drawings(updated_at DESC, created_at DESC); `); + ensurePublicationColumns(db); + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS drawings_public_slug_active_idx + ON drawings(public_slug) + WHERE public_enabled = 1 AND public_slug IS NOT NULL; + `); + const listQuery = db.query(` SELECT id, @@ -162,6 +239,38 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) { WHERE id = ?1 `); + const getPublicationQuery = db.query(` + SELECT + public_enabled AS publicEnabled, + public_slug AS publicSlug + FROM drawings + WHERE id = ?1 + `); + + const publishQuery = db.query(` + UPDATE drawings + SET + public_enabled = 1, + public_slug = ?2 + WHERE id = ?1 + `); + + const disablePublicationQuery = db.query(` + UPDATE drawings + SET + public_enabled = 0, + public_slug = NULL + WHERE id = ?1 + `); + + const getPublicDrawingQuery = db.query(` + SELECT + title, + data + FROM drawings + WHERE public_enabled = 1 AND public_slug = ?1 + `); + function listDrawings(): DrawingMeta[] { return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!); } @@ -256,6 +365,46 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) { }; } + function getDrawingPublication(id: string): DrawingPublication | null { + return readPublication(getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined); + } + + function publishDrawing(id: string, publication: { slug: string }): DrawingPublicationUpdateResult { + try { + const changes = Number(publishQuery.run(id, publication.slug).changes); + if (changes === 0) { + return { ok: false, reason: "not_found" }; + } + } catch (error) { + if (isSlugConflictError(error)) { + return { ok: false, reason: "slug_conflict" }; + } + + throw error; + } + + return { + ok: true, + publication: getDrawingPublication(id)!, + }; + } + + function disableDrawingPublication(id: string): DrawingPublicationUpdateResult { + const changes = Number(disablePublicationQuery.run(id).changes); + if (changes === 0) { + return { ok: false, reason: "not_found" }; + } + + return { + ok: true, + publication: { enabled: false }, + }; + } + + function getPublicDrawing(slug: string) { + return readPublicDrawing(getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined); + } + function close() { db.close(false); } @@ -270,6 +419,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) { ensureInitialDrawing, updateDrawing, deleteDrawing, + getDrawingPublication, + publishDrawing, + disableDrawingPublication, + getPublicDrawing, }; } diff --git a/src/server/dev-server.test.ts b/src/server/dev-server.test.ts new file mode 100644 index 0000000..1afd37f --- /dev/null +++ b/src/server/dev-server.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test"; +import { createDevServer } from "./dev-server"; + +describe("createDevServer", () => { + test("serves the spa for private and public drawing routes", () => { + const server = createDevServer(); + + expect(Object.hasOwn(server.routes, "/d/:id")).toBe(true); + expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(true); + }); +}); diff --git a/src/server/dev-server.tsx b/src/server/dev-server.tsx index 3f053c8..a6e8b85 100644 --- a/src/server/dev-server.tsx +++ b/src/server/dev-server.tsx @@ -9,6 +9,7 @@ export function createDevServer() { routes: { ...server.routes, "/d/:id": index, + "/p/:slug": index, }, } satisfies Parameters[0]; } diff --git a/src/server/http-server.test.ts b/src/server/http-server.test.ts index 08cff3c..6ec6230 100644 --- a/src/server/http-server.test.ts +++ b/src/server/http-server.test.ts @@ -58,6 +58,37 @@ describe("createServer", () => { const { server } = createServerFixture(); expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false); + expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(false); + }); + + test("serves public drawing data through the public api", async () => { + const { server, store } = createServerFixture(); + const drawing = store.createDrawing("Published"); + store.publishDrawing(drawing.id, { slug: "published-note" }); + + const response = await server.routes["/api/public/drawings/:slug"]?.GET?.( + Object.assign(new Request("http://local/api/public/drawings/published-note"), { + params: { slug: "published-note" }, + }), + ); + + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ + title: "Published", + elements: [], + appState: { theme: "dark" }, + files: {}, + }); + }); + + test("returns 404 for unknown or unpublished public slugs", async () => { + const { server } = createServerFixture(); + + const response = await server.routes["/api/public/drawings/:slug"]?.GET?.( + Object.assign(new Request("http://local/api/public/drawings/missing"), { params: { slug: "missing" } }), + ); + + expect(response?.status).toBe(404); }); test("returns JSON 404 for unmatched requests", async () => { diff --git a/src/server/http-server.tsx b/src/server/http-server.tsx index e1d9479..57eaee1 100644 --- a/src/server/http-server.tsx +++ b/src/server/http-server.tsx @@ -29,6 +29,13 @@ export function createServer(options: CreateServerOptions = {}) { PUT: (request: Request & { params: Record }) => api.updateDrawing(request), DELETE: (request: Request & { params: Record }) => api.deleteDrawing(request), }, + "/api/public/drawings/:slug": { + GET: (request: Request & { params: Record }) => api.getPublicDrawing(request), + }, + "/api/drawings/:id/publication": { + GET: (request: Request & { params: Record }) => api.getDrawingPublication(request), + PUT: (request: Request & { params: Record }) => api.updateDrawingPublication(request), + }, }, fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }), } satisfies Parameters[0];