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