feat: read only publish of drawings
This commit is contained in:
@@ -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
@@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user