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
+48 -1
View File
@@ -1,10 +1,29 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
import { EditorCanvas } from "./components/EditorCanvas";
import { PublicViewer } from "./components/PublicViewer";
import { usePublicDrawing } from "./hooks/usePublicDrawing";
import { useDrawingSession } from "./hooks/useDrawingSession";
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
export function App() {
type AppRoute =
| { type: "private" }
| { type: "public"; slug: string };
function routeFromPath(pathname = window.location.pathname): AppRoute {
if (!pathname.startsWith("/p/")) {
return { type: "private" };
}
const slug = pathname.slice(3);
if (slug.length === 0 || slug.includes("/")) {
return { type: "private" };
}
return { type: "public", slug: decodeURIComponent(slug) };
}
function PrivateApp() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const appShellRef = useRef<HTMLDivElement | null>(null);
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
@@ -17,12 +36,18 @@ export function App() {
error,
toastMessage,
editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle,
submitTitle,
handleSceneChange,
selectDrawing,
createDrawing,
deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
} = useDrawingSession();
useEffect(() => {
@@ -98,13 +123,35 @@ export function App() {
drawings={drawings}
activeId={activeId}
activeTitle={activeTitle}
publication={publication}
publicationSlug={publicationSlug}
publicationBusy={publicationBusy}
onClose={closeSidebar}
onCreate={handleCreateDrawing}
onSelect={handleSelectDrawing}
onDelete={(drawingId) => void deleteDrawing(drawingId)}
onTitleChange={setActiveTitle}
onTitleSubmit={() => void submitTitle()}
onPublicationSlugChange={setPublicationSlug}
onPublish={() => void publishPublication()}
onDisablePublication={() => void disablePublication()}
/>
</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 />;
}
+84 -21
View File
@@ -1,16 +1,22 @@
import { type DrawingMeta } from "../../core/shared";
import { type DrawingMeta, type DrawingPublication } from "../../core/shared";
type DrawingSidebarProps = {
open: boolean;
drawings: DrawingMeta[];
activeId: string | null;
activeTitle: string;
publication: DrawingPublication;
publicationSlug: string;
publicationBusy: boolean;
onClose: () => void;
onCreate: () => void;
onSelect: (drawingId: string) => void;
onDelete: (drawingId: string) => void;
onTitleChange: (title: string) => void;
onTitleSubmit: () => void;
onPublicationSlugChange: (slug: string) => void;
onPublish: () => void;
onDisablePublication: () => void;
};
function DrawerIcon() {
@@ -42,13 +48,22 @@ export function DrawingSidebar({
drawings,
activeId,
activeTitle,
publication,
publicationSlug,
publicationBusy,
onClose,
onCreate,
onSelect,
onDelete,
onTitleChange,
onTitleSubmit,
onPublicationSlugChange,
onPublish,
onDisablePublication,
}: DrawingSidebarProps) {
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
const publicationStatus = publication.enabled ? `Published at ${publicPath}` : "Not published";
return (
<>
<div
@@ -75,32 +90,80 @@ export function DrawingSidebar({
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
>
{drawing.id === activeId ? (
<div className="drawing-link">
<input
className="title-input"
value={activeTitle}
onChange={(event) => onTitleChange(event.target.value)}
onBlur={onTitleSubmit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<div className="drawing-link drawing-link-active">
<div className="drawing-item-header">
<input
className="title-input"
value={activeTitle}
onChange={(event) => onTitleChange(event.target.value)}
onBlur={onTitleSubmit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<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>
) : (
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}>
<span className="drawing-title">{drawing.title}</span>
</button>
)}
<button
type="button"
className="icon-button"
onClick={() => onDelete(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
{drawing.id !== activeId ? (
<button
type="button"
className="icon-button"
onClick={() => onDelete(drawing.id)}
aria-label={`Delete ${drawing.title}`}
>
×
</button>
) : null}
</div>
))}
</div>
+1 -1
View File
@@ -1,11 +1,11 @@
import { memo } from "react";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { Excalidraw } from "@excalidraw/excalidraw";
import type {
AppState,
BinaryFiles,
ExcalidrawInitialDataState,
} from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type ScenePayload } from "../../core/shared";
type EditorCanvasProps = {
+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 { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "../../core/shared";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingPublication, type ScenePayload } from "../../core/shared";
type DrawingResponse = DrawingMeta & ScenePayload;
type PublicationResponse = DrawingPublication;
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
type UpdateResponse = { ok: true; drawing: DrawingMeta };
@@ -70,10 +71,14 @@ export function useDrawingSession() {
const [error, setError] = useState<string | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
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 currentTitleRef = useRef(activeTitle);
const latestSceneRef = useRef<ScenePayload | null>(null);
const publicationEnabledRef = useRef(false);
const ignoreChangeRef = useRef(false);
const saveTimeoutRef = useRef<number | null>(null);
const toastTimeoutRef = useRef<number | null>(null);
@@ -106,6 +111,18 @@ export function useDrawingSession() {
return list;
}, []);
const applyPublication = useCallback((nextPublication: DrawingPublication) => {
publicationEnabledRef.current = nextPublication.enabled;
setPublication(nextPublication);
if (nextPublication.enabled) {
setPublicationSlugState(nextPublication.slug);
return;
}
setPublicationSlugState("");
}, []);
const loadDrawing = useCallback(
async (drawingId: string) => {
const version = ++loadVersionRef.current;
@@ -113,9 +130,10 @@ export function useDrawingSession() {
setError(null);
try {
const [list, drawing] = await Promise.all([
const [list, drawing, nextPublication] = await Promise.all([
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
requestJson<PublicationResponse>(`/api/drawings/${drawingId}/publication`, { method: "GET" }),
]);
if (version !== loadVersionRef.current) {
@@ -134,6 +152,7 @@ export function useDrawingSession() {
setActiveId(drawing.id);
setActiveTitleState(drawing.title);
setScene(nextScene);
applyPublication(nextPublication);
setEditorReloadNonce((current) => current + 1);
} catch (loadError) {
if (version !== loadVersionRef.current) {
@@ -147,7 +166,7 @@ export function useDrawingSession() {
}
}
},
[],
[applyPublication],
);
const saveScene = useCallback(
@@ -350,6 +369,72 @@ export function useDrawingSession() {
[navigateToDrawing],
);
const setPublicationSlug = useCallback((slug: string) => {
setPublicationSlugState(slug);
}, []);
const publishPublication = useCallback(async () => {
const drawingId = currentIdRef.current;
if (!drawingId) {
return;
}
setPublicationBusy(true);
setError(null);
showToast("Publishing...");
try {
await flushPendingSave();
const currentDrawingId = currentIdRef.current;
if (!currentDrawingId) {
return;
}
const body = await requestJson<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(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
@@ -441,11 +526,17 @@ export function useDrawingSession() {
error,
toastMessage,
editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle,
submitTitle,
handleSceneChange,
selectDrawing,
createDrawing,
deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
};
}
+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);
}
.drawing-link-active {
display: grid;
gap: 10px;
}
.drawing-item-header {
display: flex;
align-items: center;
gap: 8px;
}
.drawing-delete-button {
flex: 0 0 32px;
margin-left: auto;
}
.drawing-title,
.title-input {
display: block;
@@ -152,6 +168,44 @@ input {
padding: 6px 8px;
}
.publication-panel {
display: grid;
gap: 8px;
padding: 8px 0 4px;
}
.publication-label,
.publication-status {
color: color-mix(in srgb, var(--app-text-color) 75%, white);
font-size: 0.75rem;
}
.publication-input {
font-family:
ui-monospace,
SFMono-Regular,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
monospace;
}
.publication-actions {
display: flex;
gap: 8px;
}
.publication-link {
color: inherit;
text-decoration: none;
}
.publication-link:hover {
text-decoration: underline;
}
.editor-shell {
position: relative;
height: 100%;
@@ -270,6 +324,7 @@ input {
}
.editor-frame,
.public-viewer-shell,
.editor-loading {
height: 100%;
}