From a622dc8456f0bd58e71b6337a5742d31f1bae99c Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Wed, 10 Jun 2026 17:27:18 +0200 Subject: [PATCH 1/3] asset handling wip for boards, added advanced section to production panel --- components/board/BoardCanvas.module.css | 29 +++ components/board/BoardCanvas.tsx | 123 ++++++++++-- components/board/BoardCard.tsx | 109 +++++----- components/navbar/ProductionPanel.module.css | 102 ++++++++++ components/navbar/ProductionPanel.tsx | 87 ++++---- components/project/WritingTimer.tsx | 2 +- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/ja.json | 2 + messages/ko.json | 2 + messages/pl.json | 2 + messages/zh.json | 2 + src/context/ProjectContext.tsx | 9 + src/lib/adapters/scriptio/scriptio-adapter.ts | 73 ++----- src/lib/assets/asset-gc.ts | 79 ++++++++ src/lib/assets/asset-hash.ts | 19 ++ src/lib/assets/asset-store.ts | 88 +++++++++ src/lib/assets/use-asset-gc.ts | 31 +++ src/lib/assets/use-asset-url.ts | 71 +++++++ src/lib/import/import-project.ts | 73 ++----- .../indexeddb-storage-provider.ts | 121 +++++++++++- .../storage-provider/local-persistence.ts | 10 +- .../migrations/store-migrations.ts | 23 ++- .../storage-provider/storage-provider.ts | 35 ++++ src/lib/project/project-doc.ts | 16 ++ src/lib/project/project-state.ts | 187 +++++++++++++++++- src/tests/assets/assets.test.ts | 183 +++++++++++++++++ .../migrations/store-migration-runner.test.ts | 1 + src/tests/project/scriptio-roundtrip.test.ts | 172 ++++++++++++++++ 31 files changed, 1425 insertions(+), 234 deletions(-) create mode 100644 src/lib/assets/asset-gc.ts create mode 100644 src/lib/assets/asset-hash.ts create mode 100644 src/lib/assets/asset-store.ts create mode 100644 src/lib/assets/use-asset-gc.ts create mode 100644 src/lib/assets/use-asset-url.ts create mode 100644 src/tests/assets/assets.test.ts create mode 100644 src/tests/project/scriptio-roundtrip.test.ts diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index e943ad0e..0b566473 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -32,6 +32,12 @@ cursor: grabbing; } +/* Highlight the canvas while an image file is dragged over it. */ +.container.drag_over { + outline: 2px dashed var(--secondary-text); + outline-offset: -8px; +} + .grid { position: absolute; inset: 0; @@ -76,6 +82,29 @@ z-index: 1000; } +/* Image resource card: bare image, no colored header/border chrome. */ +.card.image_card { + border: 1px solid rgba(0, 0, 0, 0.12); + background-color: var(--main-bg); + padding: 0; + overflow: hidden; +} + +.card_image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; +} + +.card_image_placeholder { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.05); +} + .card_header { position: relative; display: flex; diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index ad1ed280..e83497f4 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -9,10 +9,14 @@ import { v7 as uuidv7 } from "uuid"; import { Trash2, Plus, Minus, Copy, ListTree } from "lucide-react"; import { useTranslations } from "next-intl"; import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; +import { importImageFile } from "@src/lib/assets/asset-store"; +import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; const GRID_SIZE = 20; const MIN_SCALE = 0.25; const MAX_SCALE = 2; +/** Largest edge (in canvas px) an image card is sized to on first drop. */ +const MAX_IMAGE_CARD_SIZE = 400; interface CardContextMenuState { position: { x: number; y: number }; @@ -25,7 +29,7 @@ interface ArrowContextMenuState { } const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }) => { - const { repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = + const { projectId, repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = useContext(ProjectContext); const t = useTranslations("board"); const projectState = repository?.getState(); @@ -37,6 +41,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const [offset, setOffset] = useState({ x: 0, y: 0 }); const [scale, setScale] = useState(1); const [isPanning, setIsPanning] = useState(false); + const [isDraggingFile, setIsDraggingFile] = useState(false); const [isSnapping, setIsSnapping] = useState(true); const [cardContextMenu, setCardContextMenu] = useState(null); const [arrowContextMenu, setArrowContextMenu] = useState(null); @@ -496,6 +501,73 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } [cards, offset, scale, isSnapping, saveCards], ); + // Highlight the canvas while an OS file drag hovers over it. + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (isReadOnly) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDraggingFile(true); + }, + [isReadOnly], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + // Ignore leave events fired when moving between the container's children. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIsDraggingFile(false); + }, []); + + // Drop image files → store each in IndexedDB (deduped) and drop an image + // card referencing its hash at the cursor. + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingFile(false); + if (isReadOnly || !projectId) return; + + const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); + if (files.length === 0) return; + + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const dropX = (e.clientX - rect.left - offset.x) / scale; + const dropY = (e.clientY - rect.top - offset.y) / scale; + + const created: BoardCardData[] = []; + for (const file of files) { + try { + const { hash, width, height } = await importImageFile(projectId, file); + const fit = Math.min(1, MAX_IMAGE_CARD_SIZE / Math.max(width, height, 1)); + const i = created.length; + created.push({ + id: uuidv7(), + type: "image", + assetId: hash, + title: "", + description: "", + color: "transparent", + x: dropX + i * 24, + y: dropY + i * 24, + width: Math.max(60, Math.round(width * fit)), + height: Math.max(60, Math.round(height * fit)), + }); + } catch (err) { + console.error("[BoardCanvas] Failed to import image:", err); + } + } + if (created.length === 0) return; + + setCards((prev) => { + const next = [...prev, ...created]; + saveCards(next); + return next; + }); + }, + [isReadOnly, projectId, offset, scale, saveCards], + ); + // Update card (with multi-drag support) const handleUpdateCard = useCallback( (updatedCard: BoardCardData) => { @@ -540,8 +612,10 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } setArrows(newArrows); saveArrows(newArrows); setCardContextMenu(null); + // Deleting an image card may orphan its asset — reconcile (debounced). + if (projectId && projectState) scheduleAssetGc(projectId, projectState); }, - [cards, arrows, saveCards, saveArrows], + [cards, arrows, saveCards, saveArrows, projectId, projectState], ); // Change card color @@ -769,10 +843,13 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
@@ -899,6 +976,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } -
- {DEFAULT_ITEM_COLORS.map((color) => ( -
+ {/* Color + outline apply to text notes; image cards have neither. */} + {cardContextMenu.card.type !== "image" && ( +
+ {DEFAULT_ITEM_COLORS.map((color) => ( +
+ )}
handleDuplicateCard(cardContextMenu.card)} @@ -951,13 +1032,15 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }

{t("duplicate")}

-
handleSendToOutline(cardContextMenu.card)} - > - -

{t("sendToOutline")}

-
+ {cardContextMenu.card.type !== "image" && ( +
handleSendToOutline(cardContextMenu.card)} + > + +

{t("sendToOutline")}

+
+ )}
handleDeleteCard(cardContextMenu.card.id)} diff --git a/components/board/BoardCard.tsx b/components/board/BoardCard.tsx index d8de0bb4..60166e9f 100644 --- a/components/board/BoardCard.tsx +++ b/components/board/BoardCard.tsx @@ -4,9 +4,11 @@ import { useRef, useState, useCallback, useEffect } from "react"; import styles from "./BoardCanvas.module.css"; import { useTranslations } from "next-intl"; import { BoardCardData } from "@src/lib/project/project-state"; +import { useAssetUrl } from "@src/lib/assets/use-asset-url"; interface BoardCardProps { card: BoardCardData; + projectId: string; scale: number; isSnapping: boolean; gridSize: number; @@ -20,6 +22,7 @@ interface BoardCardProps { const BoardCard = ({ card, + projectId, scale, isSnapping, gridSize, @@ -30,6 +33,9 @@ const BoardCard = ({ isConnecting, isSelected, }: BoardCardProps) => { + const isImage = card.type === "image"; + // Only resolves bytes for image cards (null assetId is a no-op for text notes). + const imageUrl = useAssetUrl(projectId, isImage ? card.assetId : null); const t = useTranslations("board"); const cardRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -237,63 +243,74 @@ const BoardCard = ({ return (
-
- {isEditingTitle ? ( - setLocalTitle(e.target.value)} - onBlur={handleTitleBlur} - onKeyDown={handleTitleKeyDown} - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - placeholder={t("titlePlaceholder")} - autoFocus - /> + {isImage ? ( + imageUrl ? ( + // Blob object URLs can't be optimized by next/image; a plain is correct here. + // eslint-disable-next-line @next/next/no-img-element + ) : ( - - {card.title || t("untitled")} - - )} -
- -
-

- {card.description} -

- {isEditing && ( -