From acbd86a4af527c334ade195762a5feeae5fcf771 Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 22 Apr 2026 18:12:04 +0700 Subject: [PATCH] styling/moodboard page (#1407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Moodboard polish A pass to make the Moodboard feel consistent with the rest of the app. ### What's new - **Floating toolbar** shared between the 3D Editor and Moodboard - centered and no longer glued to the top edge. - **Moodboard toolbar** rebuilt to match: mode picker (Select / Lasso / Text), a primary `+` dropdown for **Upload Image** and **Pick from Library**, plus Group, Fit-to-grid, Pack, Auto-group, Undo / Redo, Delete. - **Empty state CTA** in the center of a blank moodboard with the same Select Image / Pick from Library flow PageDraw uses. - **Theme support** across the moodboard - background, grid dots, toolbar, and empty-state card all adapt to light / gray / black / aurora / sunset themes. - **Platform-aware shortcut hints** in toolbar tooltips - `⌘G` on Mac, `Ctrl+G` on Windows / Linux. - **Topbar titles** added for Moodboard and Storyboard tabs. --- .../FloatingToolbar/FloatingToolbar.tsx | 66 +++++ .../reusable/FloatingToolbar/index.ts | 1 + .../app/src/components/reusable/index.ts | 1 + .../src/components/signaled/TopBar/TopBar.tsx | 4 + .../comps/Controls3D/Controls3D.tsx | 275 +++++++++--------- .../pages/PageMoodboard/EmptyMoodboardCTA.tsx | 47 +++ .../app/src/pages/PageMoodboard/Moodboard.tsx | 32 +- .../pages/PageMoodboard/MoodboardStage.tsx | 10 +- .../pages/PageMoodboard/MoodboardToolbar.tsx | 196 ++++++++----- .../pages/PageMoodboard/overlays/shortcuts.ts | 8 + .../PageMoodboard/useMoodboardImageEntry.tsx | 138 +++++++++ .../src/pages/PageMoodboard/useThemeFgRgb.ts | 33 +++ .../src/lib/button-icon-select.tsx | 14 +- .../components/tooltip/src/lib/tooltip.tsx | 2 +- 14 files changed, 590 insertions(+), 237 deletions(-) create mode 100644 frontend/apps/artcraft/app/src/components/reusable/FloatingToolbar/FloatingToolbar.tsx create mode 100644 frontend/apps/artcraft/app/src/components/reusable/FloatingToolbar/index.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboard/EmptyMoodboardCTA.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboard/useMoodboardImageEntry.tsx create mode 100644 frontend/apps/artcraft/app/src/pages/PageMoodboard/useThemeFgRgb.ts diff --git a/frontend/apps/artcraft/app/src/components/reusable/FloatingToolbar/FloatingToolbar.tsx b/frontend/apps/artcraft/app/src/components/reusable/FloatingToolbar/FloatingToolbar.tsx new file mode 100644 index 0000000000..f1103260dd --- /dev/null +++ b/frontend/apps/artcraft/app/src/components/reusable/FloatingToolbar/FloatingToolbar.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from "react"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { Button } from "@storyteller/ui-button"; +import { Tooltip } from "@storyteller/ui-tooltip"; +import { twMerge } from "tailwind-merge"; + +interface FloatingToolbarProps { + children: ReactNode; + className?: string; +} + +export const FloatingToolbar = ({ + children, + className, +}: FloatingToolbarProps) => ( +
+
+
{children}
+
+
+); + +export const FloatingToolbarDivider = () => ( + + | + +); + +interface FloatingToolbarButtonProps { + icon: IconDefinition; + label: string; + active?: boolean; + disabled?: boolean; + onClick: () => void; + tooltipDelay?: number; +} + +export const FloatingToolbarButton = ({ + icon, + label, + active, + disabled, + onClick, + tooltipDelay = 300, +}: FloatingToolbarButtonProps) => ( + + - - ) : ( - - - )} + + + ), + action: "presets", + }, + { + label: "My Library", + selected: false, + icon: ( + + ), + action: "library", + divider: true, + }, + { + label: "Upload 3D Model", + selected: false, + icon: ( + + ), + action: "upload-3d", + }, + { + label: "Upload Image", + selected: false, + icon: ( + + ), + action: "upload-image", + }, + { + label: "Upload Splat", + selected: false, + icon: ( + + ), + action: "upload-splat", + }, + ]} + onPanelAction={handleAddAssetAction} + showIconsInList + buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-primary/90 text-lg hover:bg-primary/70" + triggerIcon={ + + } + /> + + + + + ) : ( + + + + )} + diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/EmptyMoodboardCTA.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/EmptyMoodboardCTA.tsx new file mode 100644 index 0000000000..327917019e --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/EmptyMoodboardCTA.tsx @@ -0,0 +1,47 @@ +import { faUpload, faImages } from "@fortawesome/pro-solid-svg-icons"; +import { faThumbtack } from "@fortawesome/pro-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Button } from "@storyteller/ui-button"; + +interface Props { + onUploadClick: () => void; + onGalleryClick: () => void; +} + +export const EmptyMoodboardCTA = ({ onUploadClick, onGalleryClick }: Props) => ( +
+
+ +
+

+ Start your moodboard +

+

+ Drop, paste, or pick images from your library to start collecting + inspiration. +

+
+
+ + +
+
+
+); diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/Moodboard.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/Moodboard.tsx index 4760d15183..bb13d05315 100644 --- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/Moodboard.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/Moodboard.tsx @@ -9,8 +9,10 @@ import { usePasteHandler } from "./interactions/usePasteHandler"; import { useGalleryDropEvent } from "./interactions/useGalleryDropEvent"; import { useKeyboardShortcuts } from "./interactions/useKeyboardShortcuts"; import { useShortcutCheatsheet } from "./interactions/useShortcutCheatsheet"; +import { useMoodboardImageEntry } from "./useMoodboardImageEntry"; import { RecenterIndicator } from "./overlays/RecenterIndicator"; import { ShortcutCheatsheet } from "./overlays/ShortcutCheatsheet"; +// import { EmptyMoodboardCTA } from "./EmptyMoodboardCTA"; export const Moodboard = () => { const containerRef = useRef(null); @@ -18,12 +20,15 @@ export const Moodboard = () => { const deleteSelected = useMoodboardStore((s) => s.deleteSelected); const setSelection = useMoodboardStore((s) => s.setSelection); const editingTextId = useMoodboardStore((s) => s.transient.editingTextId); + // const isEmpty = useMoodboardStore((s) => s.rootOrder.length === 0); useUndoRedo(true); usePasteHandler(true, stageRef); useGalleryDropEvent(true, stageRef); useKeyboardShortcuts(true); const cheatsheetVisible = useShortcutCheatsheet(); + const { triggerUpload, triggerGallery, modals } = + useMoodboardImageEntry(stageRef); // Delete / Backspace removes the current selection. Skip when typing in // an input or while a text node is in edit mode. @@ -45,14 +50,27 @@ export const Moodboard = () => { }, [deleteSelected, setSelection, editingTextId]); return ( -
- -
- - - - +
+ + + + + {/* {isEmpty && ( + + )} */} +
+
+ {modals}
); }; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx index 6965ad2a94..eda179128a 100644 --- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardStage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from "react"; import Konva from "konva"; import { Stage, Layer, Rect, Line, Group, Transformer } from "react-konva"; +import { useThemeFgRgb } from "./useThemeFgRgb"; import { useShallow } from "zustand/react/shallow"; import { useMoodboardStore } from "./MoodboardStore"; import { MoodboardBackground } from "./MoodboardBackground"; @@ -44,6 +45,7 @@ export const MoodboardStage = ({ containerRef, stageRef }: Props) => { const zoom = viewport.zoom; const pan = viewport.pan; + const fgRgb = useThemeFgRgb(); // Track wrapper size with ResizeObserver so the Stage fills its container. // The size lives in the store so overlays (recenter indicator, future @@ -135,17 +137,11 @@ export const MoodboardStage = ({ containerRef, stageRef }: Props) => { style={{ cursor: tool === "text" ? "text" : "default" }} > - diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx index 3bb767dda1..95a4a5dae7 100644 --- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/MoodboardToolbar.tsx @@ -1,8 +1,6 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faMousePointer, faDrawPolygon, - faTextHeight, faObjectGroup, faObjectUngroup, faGripVertical, @@ -11,22 +9,44 @@ import { faRotateLeft, faRotateRight, faTrash, + faPlus, + faImages, + faArrowUpFromBracket, + faText, } from "@fortawesome/pro-solid-svg-icons"; -import { twMerge } from "tailwind-merge"; +import { ButtonIconSelect } from "@storyteller/ui-button-icon-select"; +import { PopoverMenu } from "@storyteller/ui-popover"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tooltip } from "@storyteller/ui-tooltip"; +import { + FloatingToolbar, + FloatingToolbarButton, + FloatingToolbarDivider, +} from "~/components/reusable/FloatingToolbar"; import { useMoodboardStore } from "./MoodboardStore"; import { Tool } from "./types"; +import { MOD, fmtShortcut } from "./overlays/shortcuts"; import { computeFitToGridPatches } from "./layout/fitToGrid"; import { computeAABB } from "./layout/geometry"; import { computePackPatches } from "./layout/packCollage"; import { clusterByProximity } from "./layout/clusterProximity"; -const TOOLS: Array<{ id: Tool; icon: typeof faMousePointer; label: string }> = [ - { id: "select", icon: faMousePointer, label: "Select" }, - { id: "lasso", icon: faDrawPolygon, label: "Lasso" }, - { id: "text", icon: faTextHeight, label: "Text" }, +const MODES: Array<{ + value: Tool; + icon: typeof faMousePointer; + tooltip: string; +}> = [ + { value: "select", icon: faMousePointer, tooltip: "Select (V)" }, + { value: "lasso", icon: faDrawPolygon, tooltip: "Lasso (L)" }, + { value: "text", icon: faText, tooltip: "Text (T)" }, ]; -export const MoodboardToolbar = () => { +interface Props { + onUploadClick: () => void; + onGalleryClick: () => void; +} + +export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => { const tool = useMoodboardStore((s) => s.tool); const setTool = useMoodboardStore((s) => s.setTool); const group = useMoodboardStore((s) => s.group); @@ -50,8 +70,7 @@ export const MoodboardToolbar = () => { }; const handlePackCollage = () => { - const ids = - selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder; + const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder; const targetNodes = ids .map((id) => nodes[id]) .filter((n) => n && n.parentId === null); @@ -62,101 +81,128 @@ export const MoodboardToolbar = () => { }; const handleAutoGroup = () => { - const ids = - selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder; + const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder; const targetNodes = ids .map((id) => nodes[id]) .filter((n) => n && n.parentId === null); if (targetNodes.length < 2) return; const clusters = clusterByProximity(targetNodes); const clusterIds = clusters.map((c) => c.map((n) => n.id)); - // One atomic store mutation: single history entry, selection ends on - // the freshly-created groups so the user immediately sees their effect. groupClusters(clusterIds); }; + const handleAddAction = (action: string) => { + if (action === "upload") onUploadClick(); + else if (action === "library") onGalleryClick(); + }; + return ( -
-
- {TOOLS.map((t) => ( - setTool(t.id)} - /> - ))} -
- - + + + ), + action: "upload", + }, + { + label: "Pick from Library", + selected: false, + icon: , + action: "library", + }, + ]} + onPanelAction={handleAddAction} + showIconsInList + buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-primary/90 text-lg text-white hover:bg-primary/70" + triggerIcon={} + /> + + + + + setTool(value as Tool)} + selectedOption={tool} + tooltipDelay={100} + /> + + + + group()} disabled={selectedIds.size < 2} + tooltipDelay={100} /> - ungroup()} disabled={selectedIds.size === 0} + tooltipDelay={100} /> - - + + - - -
- - - -
-
+ + + + + + + ); }; - -interface ButtonProps { - icon: typeof faMousePointer; - label: string; - active?: boolean; - disabled?: boolean; - onClick: () => void; -} - -const ToolbarButton = ({ icon, label, active, disabled, onClick }: ButtonProps) => ( - -); - -const Divider = () =>
; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts index cd2cea4f50..b7e46e2bf0 100644 --- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/overlays/shortcuts.ts @@ -16,6 +16,14 @@ const isMac = export const MOD = isMac ? "⌘" : "Ctrl"; +// Renders a key combo as a compact tooltip label. Mac uses adjacent symbols +// (⌘⇧Z); other platforms use "+" separators (Ctrl+Shift+Z). Pass keys in +// order, e.g. [MOD, "Shift", "Z"]. +export const fmtShortcut = (keys: string[]): string => + isMac + ? keys.map((k) => (k === "Shift" ? "⇧" : k)).join("") + : keys.join("+"); + export const SHORTCUTS: ShortcutRow[] = [ // Tools { section: "Tools", label: "Select tool", keys: ["V"] }, diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/useMoodboardImageEntry.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useMoodboardImageEntry.tsx new file mode 100644 index 0000000000..d2216f0635 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useMoodboardImageEntry.tsx @@ -0,0 +1,138 @@ +import { ReactNode, useCallback, useRef, useState } from "react"; +import Konva from "konva"; +import toast from "react-hot-toast"; +import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi"; +import { + GalleryModal, + GalleryItem, +} from "@storyteller/ui-gallery-modal"; +import { uploadImage } from "~/components/reusable/UploadModalImage/utilities/uploadImage"; +import { useMoodboardStore } from "./MoodboardStore"; +import { Vec2 } from "./types"; + +const measure = (url: string): Promise<{ w: number; h: number }> => + new Promise((resolve) => { + const img = new window.Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight }); + img.onerror = () => resolve({ w: 320, h: 320 }); + img.src = url; + }); + +const stageCenter = (stage: Konva.Stage | null): Vec2 => { + if (!stage) return { x: 400, y: 400 }; + return { + x: (stage.width() / 2 - stage.x()) / stage.scaleX(), + y: (stage.height() / 2 - stage.y()) / stage.scaleY(), + }; +}; + +interface UseMoodboardImageEntryReturn { + triggerUpload: () => void; + triggerGallery: () => void; + modals: ReactNode; +} + +// Owns the file input + GalleryModal that feed the moodboard. Both the empty +// state CTA and the toolbar `+` dropdown drive the same triggers, so the +// upload + library flows are defined once here. +export const useMoodboardImageEntry = ( + stageRef: React.RefObject, +): UseMoodboardImageEntryReturn => { + const addImage = useMoodboardStore((s) => s.addImage); + const fileInputRef = useRef(null); + const [galleryOpen, setGalleryOpen] = useState(false); + const [selectedGalleryIds, setSelectedGalleryIds] = useState([]); + + const triggerUpload = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const triggerGallery = useCallback(() => { + setGalleryOpen(true); + }, []); + + const handleFiles = useCallback( + async (files: FileList) => { + const center = stageCenter(stageRef.current); + for (const file of Array.from(files)) { + if (!file.type.startsWith("image/")) continue; + const blobUrl = URL.createObjectURL(file); + try { + const dims = await measure(blobUrl); + addImage(blobUrl, center, dims.w, dims.h, null); + } catch { + addImage(blobUrl, center, 320, 320, null); + } + // Background upload so the image lands in the user's library too. + uploadImage({ + title: file.name || "Moodboard image", + assetFile: file, + progressCallback: () => {}, + }).catch((err) => { + console.error("[Moodboard] background upload failed", err); + }); + } + }, + [addImage, stageRef], + ); + + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length) { + void handleFiles(files); + } + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleGalleryClose = () => { + setGalleryOpen(false); + setSelectedGalleryIds([]); + }; + + const handleGallerySelectItem = (id: string) => { + setSelectedGalleryIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const handleUseSelected = async (items: GalleryItem[]) => { + if (items.length === 0) { + toast.error("No image selected"); + return; + } + const center = stageCenter(stageRef.current); + for (const item of items) { + const url = item.fullImage || item.thumbnail; + if (!url) continue; + const dims = await measure(url); + addImage(url, center, dims.w, dims.h, item.id ?? null); + } + handleGalleryClose(); + }; + + const modals = ( + <> + + + + ); + + return { triggerUpload, triggerGallery, modals }; +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/useThemeFgRgb.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useThemeFgRgb.ts new file mode 100644 index 0000000000..137d00787e --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageMoodboard/useThemeFgRgb.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; + +// Reads the active theme's foreground color (`--st-fg-rgb`) as a "R G B" string, +// e.g. "255 255 255" for dark themes / "17 24 39" for light. Konva fills don't +// resolve `var()`, so we expand it once and re-read whenever the theme class on +// `` changes. +const readFgRgb = (): string => { + if (typeof window === "undefined") return "255 255 255"; + const v = getComputedStyle(document.documentElement) + .getPropertyValue("--st-fg-rgb") + .trim(); + return v || "255 255 255"; +}; + +export const useThemeFgRgb = (): string => { + const [fgRgb, setFgRgb] = useState(readFgRgb); + + useEffect(() => { + const update = () => setFgRgb(readFgRgb()); + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.attributeName === "class") { + update(); + return; + } + } + }); + observer.observe(document.documentElement, { attributes: true }); + return () => observer.disconnect(); + }, []); + + return fgRgb; +}; diff --git a/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx b/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx index d905e9ad49..049bbbd858 100644 --- a/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx +++ b/frontend/libs/components/button-icon-select/src/lib/button-icon-select.tsx @@ -15,15 +15,17 @@ interface ButtonIconSelectProps { options: Option[]; onOptionChange?: (value: string) => void; selectedOption?: string; + tooltipDelay?: number; } export function ButtonIconSelect({ options, onOptionChange, selectedOption, + tooltipDelay = 300, }: ButtonIconSelectProps) { const [internalSelectedOption, setInternalSelectedOption] = useState( - selectedOption || options[0].value + selectedOption || options[0].value, ); useEffect(() => { @@ -45,7 +47,7 @@ export function ButtonIconSelect({ key={value} content={tooltip} position="bottom" - delay={300} + delay={tooltipDelay} closeOnClick > - ) + ), )}
); diff --git a/frontend/libs/components/tooltip/src/lib/tooltip.tsx b/frontend/libs/components/tooltip/src/lib/tooltip.tsx index bfbda485d8..088f5c4e53 100644 --- a/frontend/libs/components/tooltip/src/lib/tooltip.tsx +++ b/frontend/libs/components/tooltip/src/lib/tooltip.tsx @@ -288,7 +288,7 @@ export const Tooltip = ({ show={isShowing} enter={twMerge( "transition ease-out duration-200", - delay ? `delay-[${delay}ms]` : "delay-[300ms]", + delay ? `delay-[${delay}ms]` : "delay-[200ms]", )} enterFrom="opacity-0" enterTo="opacity-100"