feat: read only publish of drawings

This commit is contained in:
2026-06-01 21:31:11 +00:00
parent 22f13820ab
commit f64d5a73d2
18 changed files with 1041 additions and 39 deletions
+4 -10
View File
@@ -6,25 +6,19 @@
:80 { :80 {
encode zstd gzip encode zstd gzip
@mcp path /mcp handle /mcp {
handle @mcp {
reverse_proxy 127.0.0.1:3001 reverse_proxy 127.0.0.1:3001
} }
@api path /api/* @app path / /api/*
handle @api { handle @app {
reverse_proxy 127.0.0.1:3000
}
@root path /
handle @root {
reverse_proxy 127.0.0.1:3000 reverse_proxy 127.0.0.1:3000
} }
handle { handle {
root * /srv/public root * /srv/public
@html path /index.html /d/* @html path /index.html /d/* /p/*
header @html Cache-Control "no-cache" header @html Cache-Control "no-cache"
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$ @assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
+48 -1
View File
@@ -1,10 +1,29 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar"; import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
import { EditorCanvas } from "./components/EditorCanvas"; import { EditorCanvas } from "./components/EditorCanvas";
import { PublicViewer } from "./components/PublicViewer";
import { usePublicDrawing } from "./hooks/usePublicDrawing";
import { useDrawingSession } from "./hooks/useDrawingSession"; import { useDrawingSession } from "./hooks/useDrawingSession";
import { useThemeTokenSync } from "./hooks/useThemeTokenSync"; 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 [isSidebarOpen, setIsSidebarOpen] = useState(false);
const appShellRef = useRef<HTMLDivElement | null>(null); const appShellRef = useRef<HTMLDivElement | null>(null);
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef); const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
@@ -17,12 +36,18 @@ export function App() {
error, error,
toastMessage, toastMessage,
editorReloadNonce, editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle, setActiveTitle,
submitTitle, submitTitle,
handleSceneChange, handleSceneChange,
selectDrawing, selectDrawing,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
} = useDrawingSession(); } = useDrawingSession();
useEffect(() => { useEffect(() => {
@@ -98,13 +123,35 @@ export function App() {
drawings={drawings} drawings={drawings}
activeId={activeId} activeId={activeId}
activeTitle={activeTitle} activeTitle={activeTitle}
publication={publication}
publicationSlug={publicationSlug}
publicationBusy={publicationBusy}
onClose={closeSidebar} onClose={closeSidebar}
onCreate={handleCreateDrawing} onCreate={handleCreateDrawing}
onSelect={handleSelectDrawing} onSelect={handleSelectDrawing}
onDelete={(drawingId) => void deleteDrawing(drawingId)} onDelete={(drawingId) => void deleteDrawing(drawingId)}
onTitleChange={setActiveTitle} onTitleChange={setActiveTitle}
onTitleSubmit={() => void submitTitle()} onTitleSubmit={() => void submitTitle()}
onPublicationSlugChange={setPublicationSlug}
onPublish={() => void publishPublication()}
onDisablePublication={() => void disablePublication()}
/> />
</div> </div>
); );
} }
function PublicApp({ slug }: { slug: string }) {
const { drawing, loading, error } = usePublicDrawing(slug);
return <PublicViewer drawing={drawing} loading={loading} error={error} />;
}
export function App() {
const route = routeFromPath();
if (route.type === "public") {
return <PublicApp slug={route.slug} />;
}
return <PrivateApp />;
}
+65 -2
View File
@@ -1,16 +1,22 @@
import { type DrawingMeta } from "../../core/shared"; import { type DrawingMeta, type DrawingPublication } from "../../core/shared";
type DrawingSidebarProps = { type DrawingSidebarProps = {
open: boolean; open: boolean;
drawings: DrawingMeta[]; drawings: DrawingMeta[];
activeId: string | null; activeId: string | null;
activeTitle: string; activeTitle: string;
publication: DrawingPublication;
publicationSlug: string;
publicationBusy: boolean;
onClose: () => void; onClose: () => void;
onCreate: () => void; onCreate: () => void;
onSelect: (drawingId: string) => void; onSelect: (drawingId: string) => void;
onDelete: (drawingId: string) => void; onDelete: (drawingId: string) => void;
onTitleChange: (title: string) => void; onTitleChange: (title: string) => void;
onTitleSubmit: () => void; onTitleSubmit: () => void;
onPublicationSlugChange: (slug: string) => void;
onPublish: () => void;
onDisablePublication: () => void;
}; };
function DrawerIcon() { function DrawerIcon() {
@@ -42,13 +48,22 @@ export function DrawingSidebar({
drawings, drawings,
activeId, activeId,
activeTitle, activeTitle,
publication,
publicationSlug,
publicationBusy,
onClose, onClose,
onCreate, onCreate,
onSelect, onSelect,
onDelete, onDelete,
onTitleChange, onTitleChange,
onTitleSubmit, onTitleSubmit,
onPublicationSlugChange,
onPublish,
onDisablePublication,
}: DrawingSidebarProps) { }: DrawingSidebarProps) {
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
const publicationStatus = publication.enabled ? `Published at ${publicPath}` : "Not published";
return ( return (
<> <>
<div <div
@@ -75,7 +90,8 @@ export function DrawingSidebar({
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"} className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
> >
{drawing.id === activeId ? ( {drawing.id === activeId ? (
<div className="drawing-link"> <div className="drawing-link drawing-link-active">
<div className="drawing-item-header">
<input <input
className="title-input" className="title-input"
value={activeTitle} value={activeTitle}
@@ -87,12 +103,58 @@ export function DrawingSidebar({
} }
}} }}
/> />
<button
type="button"
className="icon-button drawing-delete-button"
onClick={() => onDelete(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
</div>
<div className="publication-panel">
<label className="publication-label" htmlFor="publication-slug">
Public name
</label>
<input
id="publication-slug"
className="title-input publication-input"
value={publicationSlug}
onChange={(event) => onPublicationSlugChange(event.target.value)}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
/>
<div className="publication-actions">
<button type="button" className="secondary-button" onClick={onPublish} disabled={publicationBusy}>
{publication.enabled ? "Update link" : "Publish"}
</button>
<button
type="button"
className="secondary-button"
onClick={onDisablePublication}
disabled={!publication.enabled || publicationBusy}
>
Unpublish
</button>
</div>
<div className="publication-status">
{publication.enabled ? (
<a href={publicPath ?? "#"} target="_blank" rel="noreferrer" className="publication-link">
{publicationStatus}
</a>
) : (
<span>{publicationStatus}</span>
)}
</div>
</div>
</div> </div>
) : ( ) : (
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}> <button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}>
<span className="drawing-title">{drawing.title}</span> <span className="drawing-title">{drawing.title}</span>
</button> </button>
)} )}
{drawing.id !== activeId ? (
<button <button
type="button" type="button"
className="icon-button" className="icon-button"
@@ -101,6 +163,7 @@ export function DrawingSidebar({
> >
× ×
</button> </button>
) : null}
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -1,11 +1,11 @@
import { memo } from "react"; import { memo } from "react";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import type { import type {
AppState, AppState,
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type ScenePayload } from "../../core/shared"; import { type ScenePayload } from "../../core/shared";
type EditorCanvasProps = { type EditorCanvasProps = {
+53
View File
@@ -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 <div className="editor-loading">{error ?? "Loading..."}</div>;
}
return (
<div className="public-viewer-shell">
<Excalidraw
initialData={drawingToInitialData(drawing)}
viewModeEnabled={true}
zenModeEnabled={true}
UIOptions={viewerUiOptions}
/>
</div>
);
});
+94 -3
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; 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 DrawingResponse = DrawingMeta & ScenePayload;
type PublicationResponse = DrawingPublication;
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta }; type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
type UpdateResponse = { ok: true; drawing: DrawingMeta }; type UpdateResponse = { ok: true; drawing: DrawingMeta };
@@ -70,10 +71,14 @@ export function useDrawingSession() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const [editorReloadNonce, setEditorReloadNonce] = useState(0); const [editorReloadNonce, setEditorReloadNonce] = useState(0);
const [publication, setPublication] = useState<DrawingPublication>({ enabled: false });
const [publicationSlug, setPublicationSlugState] = useState("");
const [publicationBusy, setPublicationBusy] = useState(false);
const currentIdRef = useRef<string | null>(activeId); const currentIdRef = useRef<string | null>(activeId);
const currentTitleRef = useRef(activeTitle); const currentTitleRef = useRef(activeTitle);
const latestSceneRef = useRef<ScenePayload | null>(null); const latestSceneRef = useRef<ScenePayload | null>(null);
const publicationEnabledRef = useRef(false);
const ignoreChangeRef = useRef(false); const ignoreChangeRef = useRef(false);
const saveTimeoutRef = useRef<number | null>(null); const saveTimeoutRef = useRef<number | null>(null);
const toastTimeoutRef = useRef<number | null>(null); const toastTimeoutRef = useRef<number | null>(null);
@@ -106,6 +111,18 @@ export function useDrawingSession() {
return list; return list;
}, []); }, []);
const applyPublication = useCallback((nextPublication: DrawingPublication) => {
publicationEnabledRef.current = nextPublication.enabled;
setPublication(nextPublication);
if (nextPublication.enabled) {
setPublicationSlugState(nextPublication.slug);
return;
}
setPublicationSlugState("");
}, []);
const loadDrawing = useCallback( const loadDrawing = useCallback(
async (drawingId: string) => { async (drawingId: string) => {
const version = ++loadVersionRef.current; const version = ++loadVersionRef.current;
@@ -113,9 +130,10 @@ export function useDrawingSession() {
setError(null); setError(null);
try { try {
const [list, drawing] = await Promise.all([ const [list, drawing, nextPublication] = await Promise.all([
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }), requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }), requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
requestJson<PublicationResponse>(`/api/drawings/${drawingId}/publication`, { method: "GET" }),
]); ]);
if (version !== loadVersionRef.current) { if (version !== loadVersionRef.current) {
@@ -134,6 +152,7 @@ export function useDrawingSession() {
setActiveId(drawing.id); setActiveId(drawing.id);
setActiveTitleState(drawing.title); setActiveTitleState(drawing.title);
setScene(nextScene); setScene(nextScene);
applyPublication(nextPublication);
setEditorReloadNonce((current) => current + 1); setEditorReloadNonce((current) => current + 1);
} catch (loadError) { } catch (loadError) {
if (version !== loadVersionRef.current) { if (version !== loadVersionRef.current) {
@@ -147,7 +166,7 @@ export function useDrawingSession() {
} }
} }
}, },
[], [applyPublication],
); );
const saveScene = useCallback( const saveScene = useCallback(
@@ -350,6 +369,72 @@ export function useDrawingSession() {
[navigateToDrawing], [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<PublicationResponse>(`/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<PublicationResponse>(`/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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
@@ -441,11 +526,17 @@ export function useDrawingSession() {
error, error,
toastMessage, toastMessage,
editorReloadNonce, editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle, setActiveTitle,
submitTitle, submitTitle,
handleSceneChange, handleSceneChange,
selectDrawing, selectDrawing,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
}; };
} }
+73
View File
@@ -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<PublicDrawing> {
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<PublicDrawing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}
+55
View File
@@ -134,6 +134,22 @@ input {
font-size: var(--app-sidebar-font-size); 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, .drawing-title,
.title-input { .title-input {
display: block; display: block;
@@ -152,6 +168,44 @@ input {
padding: 6px 8px; 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 { .editor-shell {
position: relative; position: relative;
height: 100%; height: 100%;
@@ -270,6 +324,7 @@ input {
} }
.editor-frame, .editor-frame,
.public-viewer-shell,
.editor-loading { .editor-loading {
height: 100%; height: 100%;
} }
+25
View File
@@ -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;
}
+13
View File
@@ -4,6 +4,19 @@ export type ScenePayload = {
files: Record<string, unknown>; files: Record<string, unknown>;
}; };
export type DrawingPublication =
| {
enabled: false;
}
| {
enabled: true;
slug: string;
};
export type PublicDrawing = {
title: string;
} & ScenePayload;
export type DrawingMeta = { export type DrawingMeta = {
id: string; id: string;
title: string; title: string;
+206
View File
@@ -205,4 +205,210 @@ describe("api", () => {
expect(stored.elements).toEqual([{ id: "server" }]); expect(stored.elements).toEqual([{ id: "server" }]);
cleanup(); 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();
});
}); });
+86 -1
View File
@@ -1,5 +1,6 @@
import { type DrawingStore } from "./db"; 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"; import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
type RouteRequest = Request & { type RouteRequest = Request & {
@@ -46,6 +47,10 @@ function parseExpectedRevision(raw: Record<string, unknown>): number | undefined
return value; return value;
} }
function jsonPublication(publication: DrawingPublication): Response {
return json(publication);
}
export function createApi(store: DrawingStore) { export function createApi(store: DrawingStore) {
return { return {
health() { 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) { async updateDrawing(request: RouteRequest) {
try { try {
const id = request.params?.id; const id = request.params?.id;
@@ -150,5 +173,67 @@ export function createApi(store: DrawingStore) {
return json({ ok: true, nextId: next?.id ?? null }); 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<Record<string, unknown>>(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);
}
},
}; };
} }
+94
View File
@@ -135,4 +135,98 @@ describe("drawing store", () => {
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed"); expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2); 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();
});
}); });
+155 -2
View File
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { dirname } from "node:path"; 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"; import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
type DrawingRow = { type DrawingRow = {
@@ -16,11 +16,26 @@ type DrawingRow = {
type DrawingMetaRow = Omit<DrawingRow, "data">; type DrawingMetaRow = Omit<DrawingRow, "data">;
type DrawingPublicationRow = {
publicEnabled: number;
publicSlug: string | null;
};
type PublicDrawingRow = {
title: string;
data: string;
};
export type DrawingUpdateResult = export type DrawingUpdateResult =
| { ok: true; drawing: DrawingRecord } | { ok: true; drawing: DrawingRecord }
| { ok: false; reason: "not_found" } | { ok: false; reason: "not_found" }
| { ok: false; reason: "conflict"; drawing: DrawingRecord }; | { 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<typeof createDrawingStore>; export type DrawingStore = ReturnType<typeof createDrawingStore>;
function createId(): string { 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 { function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
if (!row) { if (!row) {
return null; 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()) { export function createDrawingStore(databasePath = resolveDatabasePath()) {
mkdirSync(dirname(databasePath), { recursive: true }); mkdirSync(dirname(databasePath), { recursive: true });
@@ -86,13 +154,22 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
data TEXT NOT NULL, data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
ON drawings(updated_at DESC, created_at DESC); 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(` const listQuery = db.query(`
SELECT SELECT
id, id,
@@ -162,6 +239,38 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
WHERE id = ?1 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[] { function listDrawings(): DrawingMeta[] {
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!); 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() { function close() {
db.close(false); db.close(false);
} }
@@ -270,6 +419,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
ensureInitialDrawing, ensureInitialDrawing,
updateDrawing, updateDrawing,
deleteDrawing, deleteDrawing,
getDrawingPublication,
publishDrawing,
disableDrawingPublication,
getPublicDrawing,
}; };
} }
+11
View File
@@ -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);
});
});
+1
View File
@@ -9,6 +9,7 @@ export function createDevServer() {
routes: { routes: {
...server.routes, ...server.routes,
"/d/:id": index, "/d/:id": index,
"/p/:slug": index,
}, },
} satisfies Parameters<typeof Bun.serve>[0]; } satisfies Parameters<typeof Bun.serve>[0];
} }
+31
View File
@@ -58,6 +58,37 @@ describe("createServer", () => {
const { server } = createServerFixture(); const { server } = createServerFixture();
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false); 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 () => { test("returns JSON 404 for unmatched requests", async () => {
+7
View File
@@ -29,6 +29,13 @@ export function createServer(options: CreateServerOptions = {}) {
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request), PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request), DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
}, },
"/api/public/drawings/:slug": {
GET: (request: Request & { params: Record<string, string> }) => api.getPublicDrawing(request),
},
"/api/drawings/:id/publication": {
GET: (request: Request & { params: Record<string, string> }) => api.getDrawingPublication(request),
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawingPublication(request),
},
}, },
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }), fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
} satisfies Parameters<typeof Bun.serve>[0]; } satisfies Parameters<typeof Bun.serve>[0];