diff --git a/frontend/apps/artcraft-webapp/src/pages/home/home.tsx b/frontend/apps/artcraft-webapp/src/pages/home/home.tsx
index f920e71642..7b6f453137 100644
--- a/frontend/apps/artcraft-webapp/src/pages/home/home.tsx
+++ b/frontend/apps/artcraft-webapp/src/pages/home/home.tsx
@@ -6,6 +6,7 @@ import {
faWandMagicSparkles,
faCube,
faFilm,
+ faObjectGroup,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -69,6 +70,16 @@ const APPS: AppCard[] = [
iconColor: "text-rose-300",
badge: "BETA",
},
+ {
+ label: "Moodboard",
+ description: "Collect references and ideas to steer a generation.",
+ href: "/moodboard",
+ icon: faObjectGroup,
+ accent: "from-indigo-500/20 to-indigo-500/0",
+ iconBg: "bg-indigo-500/25 border-indigo-400/30",
+ iconColor: "text-indigo-300",
+ badge: "BETA",
+ },
];
export function Home() {
diff --git a/frontend/apps/artcraft/app/src/pages/MainApp.tsx b/frontend/apps/artcraft/app/src/pages/MainApp.tsx
index 5f7e824aa5..79d810f6fc 100644
--- a/frontend/apps/artcraft/app/src/pages/MainApp.tsx
+++ b/frontend/apps/artcraft/app/src/pages/MainApp.tsx
@@ -46,10 +46,7 @@ import {
} from "@storyteller/tauri-events";
import { SoundManager } from "@storyteller/soundboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
-import {
- DomLevels,
- usePageSceneStore,
-} from "@storyteller/ui-pagescene";
+import { DomLevels, usePageSceneStore } from "@storyteller/ui-pagescene";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
@@ -224,9 +221,17 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
case "3D":
return ;
case "APPS":
- return
;
+ return (
+
+ );
case "2D":
- return ;
+ return (
+
+ );
case "IMAGE":
return (
@@ -237,29 +242,83 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
);
case "VIDEO":
- return
;
+ return (
+
+
+
+ );
case "VIDEO_FRAME_EXTRACTOR":
- return
;
+ return (
+
+
+
+ );
case "VIDEO_WATERMARK_REMOVAL":
- return
;
+ return (
+
+
+
+ );
case "IMAGE_WATERMARK_REMOVAL":
- return
;
+ return (
+
+
+
+ );
case "IMAGE_TO_3D_OBJECT":
- return
;
+ return (
+
+
+
+ );
case "IMAGE_TO_3D_WORLD":
- return
;
+ return (
+
+
+
+ );
case "REMOVE_BACKGROUND":
- return
;
+ return (
+
+
+
+ );
case "ANGLES":
- return ;
+ return (
+
+ );
case "STORYBOARD":
- return storyboardPageEnabled ?
: null;
+ return storyboardPageEnabled ? (
+
+
+
+ ) : null;
case "BACKGROUND_CHANGE":
- return ;
+ return (
+
+ );
case "VIDEO_EDITOR":
- return ;
+ return (
+
+ );
case "MOODBOARD":
- return ;
+ // The TopBar is fixed and 56px tall, so the board sits below it and fills
+ // the rest of the viewport. h-screen + pt-[56px] (border-box) makes the
+ // board area exactly viewport-minus-topbar, and overflow-hidden keeps
+ // sub-pixel 100vh rounding from spawning page scrollbars (which w-screen
+ // ancestors would turn into a horizontal scrollbar via the Windows
+ // scrollbar gutter).
+ return (
+
+ );
default:
return null;
}
diff --git a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx b/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx
index 6797a1f3aa..d3ac63c8dc 100644
--- a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx
+++ b/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx
@@ -42,7 +42,11 @@ export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
return (
{viewMode === "grid" ? (
@@ -77,7 +81,7 @@ const ViewSwitch = ({
mode: ViewMode;
onChange: (m: ViewMode) => void;
}) => (
-
+
{OPTIONS.map((opt) => {
const active = opt.mode === mode;
return (
@@ -86,7 +90,7 @@ const ViewSwitch = ({
type="button"
onClick={() => onChange(opt.mode)}
className={[
- "flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium",
+ "flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium",
"transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-base-fg/15 text-base-fg"
@@ -112,13 +116,13 @@ const TopRightCluster = ({
endSlot?: ReactNode;
}) => (
-
+
Present
diff --git a/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.ts b/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.ts
index 721434be06..4e0cb1845c 100644
--- a/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.ts
+++ b/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.ts
@@ -86,7 +86,9 @@ interface BoardLibraryState extends PersistedLibrary {
// Selection (operates on the active board)
setSelection: (ids: Iterable) => void;
- toggleSelection: (id: string, additive: boolean) => void;
+ // Clicks accumulate; `additive` is accepted for caller compatibility but no
+ // longer changes behavior (a plain click already toggles within the set).
+ toggleSelection: (id: string, additive?: boolean) => void;
clearSelection: () => void;
deleteSelected: () => void;
}
@@ -413,10 +415,15 @@ export const useBoardLibraryStore = create()(
set({ searchQuery: "", activeTagFilters: [], ratingFilter: 0 }),
setSelection: (ids) => set({ selectedItemIds: new Set(ids) }),
- toggleSelection: (id, additive) =>
+ // Every click accumulates: a plain click adds the item to the current
+ // selection (or removes it if already selected), so clicking item after
+ // item builds up a multi-selection without needing a modifier. The
+ // `additive` flag (ctrl/cmd/shift-click) behaves identically here; the
+ // distinction is only meaningful for marquee/range selection.
+ toggleSelection: (id) =>
set((s) => {
- const next = additive ? new Set(s.selectedItemIds) : new Set();
- if (additive && s.selectedItemIds.has(id)) next.delete(id);
+ const next = new Set(s.selectedItemIds);
+ if (next.has(id)) next.delete(id);
else next.add(id);
return { selectedItemIds: next };
}),
diff --git a/frontend/libs/components/moodboard/src/lib/boards/useBoardItemEntry.tsx b/frontend/libs/components/moodboard/src/lib/boards/useBoardItemEntry.tsx
index ce685c49b8..58f0711f46 100644
--- a/frontend/libs/components/moodboard/src/lib/boards/useBoardItemEntry.tsx
+++ b/frontend/libs/components/moodboard/src/lib/boards/useBoardItemEntry.tsx
@@ -8,7 +8,8 @@ import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter";
interface UseReturn {
triggerUpload: () => void;
triggerGallery: () => void;
- addNote: () => void;
+ /** Adds an empty note and returns its id so the grid can open it for editing. */
+ addNote: () => string;
modals: ReactNode;
}
@@ -34,7 +35,11 @@ export const useBoardItemEntry = (
const addNote = useCallback(() => {
const store = useBoardLibraryStore.getState();
- store.addTextItem(store.ensureActiveBoard(), "New note");
+ // Empty text → the card shows its "Note" placeholder and the grid drops
+ // straight into edit mode so the user types instead of clearing "New note".
+ const id = store.addTextItem(store.ensureActiveBoard(), "");
+ store.setSelection([id]);
+ return id;
}, []);
// Place the image immediately from a blob, then (if the platform supports it)
diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx
index cf1605eb32..140f77c3fd 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx
@@ -29,6 +29,12 @@ interface Props {
onOpen: (id: string) => void;
onDelete: (id: string) => void;
onUseReference: (id: string) => void;
+ /** This text card is in inline-edit mode (renders an editable textarea). */
+ editing?: boolean;
+ /** Enter inline edit for a text card (double-click). */
+ onEditText?: (id: string) => void;
+ /** Commit edited note text (blur / Enter). */
+ onCommitText?: (id: string, text: string) => void;
}
const KIND_NOUN: Record = {
@@ -60,6 +66,9 @@ const BoardCardInner = ({
onOpen,
onDelete,
onUseReference,
+ editing = false,
+ onEditText,
+ onCommitText,
}: Props) => {
const rootRef = useRef(null);
const [entering, setEntering] = useState(
@@ -107,7 +116,11 @@ const BoardCardInner = ({
if (e.button !== 0) return;
onSelect(item.id, e.shiftKey || e.metaKey || e.ctrlKey);
}}
- onDoubleClick={() => onOpen(item.id)}
+ onDoubleClick={() =>
+ item.kind === "text" && onEditText
+ ? onEditText(item.id)
+ : onOpen(item.id)
+ }
onFocus={() => onFocusCard?.(item.id)}
onKeyDown={(e) => onKeyNav?.(item.id, e)}
>
@@ -129,7 +142,11 @@ const BoardCardInner = ({
>
{/* Inner core — concentric radius, holds the content. */}
-
+
{/* Rating — persistent when set (Lightroom-style triage marker).
No backdrop-blur: this tile lives in a virtualized scroll
@@ -211,7 +228,15 @@ const RailButton = ({
);
-const CardContent = ({ item }: { item: BoardItem }) => {
+const CardContent = ({
+ item,
+ editing,
+ onCommitText,
+}: {
+ item: BoardItem;
+ editing?: boolean;
+ onCommitText?: (id: string, text: string) => void;
+}) => {
switch (item.kind) {
case "image":
return (
@@ -243,9 +268,22 @@ const CardContent = ({ item }: { item: BoardItem }) => {
);
case "text":
- return (
-
-
+ return editing && onCommitText ? (
+ onCommitText(item.id, text)}
+ />
+ ) : (
+ // Extra top padding clears the selection check (top-left) so it never
+ // overlaps the first line of the note.
+
+
e.stopPropagation()}
+ className="whitespace-pre-wrap text-sm leading-relaxed text-base-fg/90"
+ >
{item.text || "Note"}
@@ -287,4 +325,53 @@ const CardContent = ({ item }: { item: BoardItem }) => {
}
};
+// Inline note editor. Autofocuses, commits on blur or Enter (Shift+Enter inserts
+// a newline), reverts on Escape. Pointer events are stopped so typing/selecting
+// inside the textarea never triggers card selection, drag, or marquee.
+const NoteEditor = ({
+ initial,
+ onCommit,
+}: {
+ initial: string;
+ onCommit: (text: string) => void;
+}) => {
+ const ref = useRef(null);
+ const [draft, setDraft] = useState(initial);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.focus();
+ el.select();
+ }, []);
+
+ return (
+ e.stopPropagation()}
+ onDoubleClick={(e) => e.stopPropagation()}
+ >
+
+ );
+};
+
export const BoardCard = memo(BoardCardInner);
diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx
index aab70a32cf..46d3046b05 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx
@@ -1,14 +1,18 @@
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpFromBracket,
faImages,
} from "@fortawesome/pro-regular-svg-icons";
+import { Button } from "@storyteller/ui-button";
interface Props {
onUpload: () => void;
onLibrary: () => void;
}
+// Shared sizing so both CTAs match the webapp create pages' empty-state buttons
+// (h-12, pill, semibold). Centralized so the pair stays visually aligned.
+const CTA_CLASS = "h-12 px-6 text-base font-semibold rounded-full";
+
// Editorial empty state — the board's first impression. Doubles as the
// drop / paste affordance (the whole grid accepts drops; this just says so).
export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {
@@ -27,27 +31,22 @@ export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {
-
Upload
-
-
-
-
-
+
-
From library
-
+
diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardGrid.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardGrid.tsx
index 6662da27bb..2943361c66 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/BoardGrid.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/BoardGrid.tsx
@@ -30,6 +30,10 @@ interface Props {
onOpen: (id: string) => void;
onUseReference: (id: string) => void;
onDelete: (id: string) => void;
+ // Inline text editing: which text card is being edited, plus enter/commit.
+ editingTextId?: string | null;
+ onEditText?: (id: string) => void;
+ onCommitText?: (id: string, text: string) => void;
// When present (non-empty), the grid groups items into section lanes with
// header rows. Absent → flat masonry (the default).
sections?: BoardSection[];
@@ -62,6 +66,9 @@ export const BoardGrid = ({
onOpen,
onUseReference,
onDelete,
+ editingTextId,
+ onEditText,
+ onCommitText,
sections,
onRenameSection,
onDeleteSection,
@@ -298,6 +305,9 @@ export const BoardGrid = ({
onOpen={onOpen}
onUseReference={onUseReference}
onDelete={onDelete}
+ editing={editingTextId === pos.id}
+ onEditText={onEditText}
+ onCommitText={onCommitText}
/>
);
})}
diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardGridToolbar.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardGridToolbar.tsx
index 3478252f21..cc2c07cba7 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/BoardGridToolbar.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/BoardGridToolbar.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
@@ -18,6 +19,7 @@ import { ToolbarShell, ToolbarIconButton, ToolbarDivider } from "../ui/Toolbar";
interface Props {
boardName: string;
+ onRenameBoard: (name: string) => void;
itemCount: number;
density: GridDensity;
onDensityChange: (d: GridDensity) => void;
@@ -52,6 +54,7 @@ const DENSITY_LABEL: Record
= {
// its shell, buttons, and dividers with the Canvas toolbar (see ../ui/Toolbar).
export const BoardGridToolbar = ({
boardName,
+ onRenameBoard,
itemCount,
density,
onDensityChange,
@@ -73,10 +76,8 @@ export const BoardGridToolbar = ({
-
- {boardName}
-
-
+
+
{itemCount} {itemCount === 1 ? "item" : "items"}
@@ -179,3 +180,60 @@ export const BoardGridToolbar = ({
);
};
+
+// Inline-editable board title — click to rename (mirrors SectionHeader). Commits
+// on Enter / blur, Escape cancels; empty or unchanged input is ignored.
+const BoardTitle = ({
+ name,
+ onRename,
+}: {
+ name: string;
+ onRename: (name: string) => void;
+}) => {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(name);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (editing) {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ }, [editing]);
+
+ const commit = () => {
+ const next = draft.trim();
+ if (next && next !== name) onRename(next);
+ setEditing(false);
+ };
+
+ if (editing) {
+ return (
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") commit();
+ else if (e.key === "Escape") setEditing(false);
+ }}
+ onBlur={commit}
+ className="min-w-0 rounded-md bg-base-fg/5 px-1.5 py-0.5 text-sm font-semibold leading-tight text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ />
+ );
+ }
+
+ return (
+ {
+ setDraft(name);
+ setEditing(true);
+ }}
+ className="truncate rounded-md px-1.5 py-0.5 text-left text-sm font-semibold leading-tight text-base-fg transition-colors hover:bg-base-fg/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ >
+ {name}
+
+ );
+};
diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx
index af4b768364..f171c435de 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/BoardGridView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useBoardLibraryStore } from "../boards/BoardLibraryStore";
import {
@@ -51,6 +51,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
const removeItems = useBoardLibraryStore((s) => s.removeItems);
const updateItem = useBoardLibraryStore((s) => s.updateItem);
const addColorItem = useBoardLibraryStore((s) => s.addColorItem);
+ const renameBoard = useBoardLibraryStore((s) => s.renameBoard);
const createSection = useBoardLibraryStore((s) => s.createSection);
const renameSection = useBoardLibraryStore((s) => s.renameSection);
const deleteSection = useBoardLibraryStore((s) => s.deleteSection);
@@ -66,6 +67,20 @@ export const BoardGridView = ({ active, adapter }: Props) => {
const [dragOver, setDragOver] = useState(false);
const [openId, setOpenId] = useState(null);
+ // Which text card is in inline-edit mode (e.g. a freshly-added note).
+ const [editingTextId, setEditingTextId] = useState(null);
+
+ const handleAddNote = useCallback(
+ () => setEditingTextId(addNote()),
+ [addNote],
+ );
+ const commitText = useCallback(
+ (id: string, text: string) => {
+ if (board) updateItem(board.id, id, { text });
+ setEditingTextId((cur) => (cur === id ? null : cur));
+ },
+ [board, updateItem],
+ );
useEffect(() => {
if (active) ensureActiveBoard();
@@ -112,6 +127,18 @@ export const BoardGridView = ({ active, adapter }: Props) => {
.filter((it): it is BoardItem => Boolean(it)),
);
};
+ // "Use as reference" only applies to reference-capable items (images with a
+ // media token) — never notes/colors/links. Mirrors sendReference's filter.
+ const canUseReference = useMemo(
+ () =>
+ board
+ ? Array.from(selectedItemIds).some((id) => {
+ const it = board.items[id];
+ return it?.kind === "image" && Boolean(it.mediaId);
+ })
+ : false,
+ [board, selectedItemIds],
+ );
const handleNewSection = () => {
if (board) createSection(board.id);
@@ -192,7 +219,11 @@ export const BoardGridView = ({ active, adapter }: Props) => {
return (
{
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
@@ -206,6 +237,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
>
renameBoard(ensureActiveBoard(), name)}
itemCount={board?.itemOrder.length ?? 0}
density={density}
onDensityChange={setDensity}
@@ -221,7 +253,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
onUpload={triggerUpload}
onLibrary={triggerGallery}
canPickLibrary={Boolean(adapter.renderLibraryPicker)}
- onAddNote={addNote}
+ onAddNote={handleAddNote}
onAddColor={(color) => addColorItem(ensureActiveBoard(), color)}
onNewSection={handleNewSection}
/>
@@ -257,6 +289,9 @@ export const BoardGridView = ({ active, adapter }: Props) => {
onOpen={(id) => setOpenId(id)}
onUseReference={referenceById}
onDelete={(id) => board && removeItems(board.id, [id])}
+ editingTextId={editingTextId}
+ onEditText={setEditingTextId}
+ onCommitText={commitText}
sections={board?.sections}
onRenameSection={(id, name) =>
board && renameSection(board.id, id, name)
@@ -275,6 +310,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
(
- }
- buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-transparent p-0 text-base-fg/80 shadow-none hover:bg-base-fg/10 hover:text-base-fg"
- >
- {(close) => (
- {
- onAdd(color);
- close();
- }}
- />
- )}
-
+ // Tooltip matches the sibling ToolbarIconButtons (bottom, 300ms, close on click).
+
+
+ }
+ buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-transparent p-0 text-base-fg/80 shadow-none hover:bg-base-fg/10 hover:text-base-fg"
+ >
+ {(close) => (
+ {
+ onAdd(color);
+ close();
+ }}
+ />
+ )}
+
+
);
const ColorPickerPanel = ({ onAdd }: Props) => {
diff --git a/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx b/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx
index 9ca88abc15..32b278ae47 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/ItemInspector.tsx
@@ -10,6 +10,8 @@ import {
faChevronRight,
} from "@fortawesome/pro-regular-svg-icons";
import { faStar } from "@fortawesome/pro-solid-svg-icons";
+import { Modal } from "@storyteller/ui-modal";
+import { Button } from "@storyteller/ui-button";
import { BoardItem } from "../boards/boardTypes";
import { hostnameOf } from "../boards/linkMeta";
import { extractPalette } from "../boards/palette";
@@ -38,8 +40,10 @@ const KIND_LABEL: Record = {
color: "Color",
};
-// Full-screen lightbox + inspector. Large media on the left, metadata / tags /
-// palette / actions on the right. Keyboard: Esc closes, ←/→ navigate.
+// Lightbox + inspector. Mirrors the shared LightboxModal design: a bounded,
+// centered, draggable/resizable panel with the media on the left (object-contain
+// on a dark plate) and a fixed-width info/actions column on the right. The shared
+// Modal owns the backdrop, Esc, drag/resize and a11y; we own ←/→ navigation.
export const ItemInspector = ({
item,
hasPrev,
@@ -59,13 +63,12 @@ export const ItemInspector = ({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- else if (e.key === "ArrowLeft" && hasPrev) onPrev();
+ if (e.key === "ArrowLeft" && hasPrev) onPrev();
else if (e.key === "ArrowRight" && hasNext) onNext();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
- }, [onClose, onPrev, onNext, hasPrev, hasNext]);
+ }, [onPrev, onNext, hasPrev, hasNext]);
// Pull a palette for images, keyed on the image src — NOT the whole item, so
// editing tags/rating (which produce a new item object) doesn't re-decode the
@@ -83,15 +86,6 @@ export const ItemInspector = ({
};
}, [imageSrc]);
- // Lock background scroll while the lightbox is open.
- useEffect(() => {
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- return () => {
- document.body.style.overflow = prev;
- };
- }, []);
-
const commitTag = () => {
const t = tagDraft.trim().toLowerCase();
if (t) onAddTag(t);
@@ -99,42 +93,39 @@ export const ItemInspector = ({
};
return (
-
- {/* Backdrop click closes */}
-
+ {/* Invisible drag strip along the top, matching the shared lightbox. */}
+
+
+
-
-
-
-
- {/* Media */}
-
+
+ {/* Media panel — flexible width, dark plate, contained media. */}
+
+
+ {hasPrev && }
+ {hasNext && }
- {/* Inspector panel — double-bezel */}
-
-
-
-
+ {/* Info + actions — fixed width, scrollable body, buttons pinned bottom. */}
+
+
+
+
{KIND_LABEL[item.kind]}
-
-
-
- {/* Rating */}
{[1, 2, 3, 4, 5].map((n) => (
@@ -158,7 +149,6 @@ export const ItemInspector = ({
- {/* Tags */}
{item.tags.map((tag) => (
@@ -166,7 +156,7 @@ export const ItemInspector = ({
key={tag}
type="button"
onClick={() => onRemoveTag(tag)}
- className="group/tag flex items-center gap-1 rounded-full bg-base-fg/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-colors hover:bg-danger/15 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
+ className="group/tag flex items-center gap-1 rounded-full bg-base-fg/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-colors hover:bg-red/15 hover:text-red focus:outline-none focus-visible:ring-2 focus-visible:ring-red"
>
{tag}
@@ -185,7 +175,6 @@ export const ItemInspector = ({
- {/* Palette */}
{item.kind === "image" && palette.length > 0 && (
@@ -213,39 +202,32 @@ export const ItemInspector = ({
)}
+
-
-
- {/* Actions */}
-
- {item.kind === "image" && (
-
- Use as reference
-
-
-
-
- )}
-
+ {item.kind === "image" && (
+
-
- Delete
-
-
+ Use as reference
+
+ )}
+
+ Delete
+
-
+
);
};
@@ -256,10 +238,8 @@ const Section = ({
title: string;
children: React.ReactNode;
}) => (
-
-
- {title}
-
+
);
@@ -271,7 +251,7 @@ const Media = ({ item }: { item: BoardItem }) => {
);
case "video":
@@ -283,7 +263,7 @@ const Media = ({ item }: { item: BoardItem }) => {
loop
controls
playsInline
- className="max-h-full max-w-full rounded-xl shadow-2xl"
+ className="h-full w-full object-contain"
/>
);
case "color":
@@ -334,31 +314,31 @@ const Media = ({ item }: { item: BoardItem }) => {
}
};
+// Hover-reveal navigation arrow, styled to match the shared LightboxModal.
const NavArrow = ({
side,
- show,
onClick,
}: {
side: "left" | "right";
- show: boolean;
onClick: () => void;
-}) => {
- if (!show) return null;
- return (
-
-
-
- );
-};
+}) => (
+
{
+ e.stopPropagation();
+ onClick();
+ }}
+ className={[
+ "absolute top-1/2 z-30 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full",
+ "bg-black/50 text-white/70 opacity-0 transition-opacity duration-200",
+ "hover:bg-black/70 hover:text-white group-hover/nav:opacity-100 focus:outline-none",
+ side === "left" ? "left-3" : "right-3",
+ ].join(" ")}
+ >
+
+
+);
diff --git a/frontend/libs/components/moodboard/src/lib/grid/SelectionBar.tsx b/frontend/libs/components/moodboard/src/lib/grid/SelectionBar.tsx
index 818e5947e7..cb2f357c43 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/SelectionBar.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/SelectionBar.tsx
@@ -11,6 +11,9 @@ import { BoardSection } from "../boards/boardTypes";
interface Props {
count: number;
+ // True when the selection has at least one reference-capable item (an image
+ // with a media token). Notes/colors/links can't be used as a reference.
+ canUseReference: boolean;
sections: BoardSection[];
onUseReference: () => void;
onAssignToSection: (sectionId: string | null) => void;
@@ -23,6 +26,7 @@ interface Props {
// selection into a section lane; "Use as reference" is the board→generate loop.
export const SelectionBar = ({
count,
+ canUseReference,
sections,
onUseReference,
onAssignToSection,
@@ -33,44 +37,47 @@ export const SelectionBar = ({
if (count === 0) return null;
return (
-
-
+ {/* Styled to match the webapp library's multiselect bar: solid pill, filled
+ control buttons, red delete, round clear. */}
+
+
{count} selected
{/* Triage shortcut discoverability — these keys act on the selection. */}
-
+
1–5 rate
0 clear
-
-
-
- Use as reference
-
+ {canUseReference && (
+
+
+ Use as reference
+
+ )}
-
+
Delete
-
+
@@ -117,16 +124,16 @@ const MoveToMenu = ({
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
- className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
-
+
Move to
{open && (
pick(() => onAssignToSection(null))}
/>
{sections.length > 0 && (
-
+
)}
{sections.map((s) => (
pick(() => onAssignToSection(s.id))}
/>
))}
-
+
{icon ? (
@@ -193,7 +200,7 @@ const MenuItem = ({
);
const Kbd = ({ children }: { children: ReactNode }) => (
-
+
{children}
);
diff --git a/frontend/libs/components/moodboard/src/lib/grid/SmartSearchBar.tsx b/frontend/libs/components/moodboard/src/lib/grid/SmartSearchBar.tsx
index 9b01df2fe4..d8006a350c 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/SmartSearchBar.tsx
+++ b/frontend/libs/components/moodboard/src/lib/grid/SmartSearchBar.tsx
@@ -1,8 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faMagnifyingGlass,
- faXmark,
-} from "@fortawesome/pro-regular-svg-icons";
+import { faMagnifyingGlass, faXmark } from "@fortawesome/pro-regular-svg-icons";
interface Props {
query: string;
@@ -23,7 +20,7 @@ export const SmartSearchBar = ({
}: Props) => {
return (