mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-01 16:08:30 +00:00
Moodboard: webapp parity, lightbox redesign, editable notes & selection polish
Align the shared @storyteller/ui-moodboard surface with the webapp's design language across both hosts (Tauri desktop + webapp), plus several UX fixes. Theming & layout - Use `var(--st-bg, hsl(0 0% 9%))` for the workspace/grid background so desktop follows the active theme while the webapp falls back to its --background token (replaces the desktop-only bg-ui-background class). - Fix desktop overflow: the fixed TopBar is 56px, so the moodboard tab now uses h-screen + pt-[56px] + overflow-hidden, killing the stray vertical/horizontal scrollbars on an empty board. Grid view root clips too. Empty state & toolbars - Empty-state Upload / From-library CTAs now use the shared Button, matching the webapp create pages (h-12, pill, semibold; primary + secondary). - Restyle SelectionBar to match the library multiselect bar (solid pill, filled bg-ui-controls buttons, red Delete, round clear); align the "Move to" menu/items/Kbd to the same idiom. - Restyle SmartSearchBar (bg-ui-controls, rounded-xl) and tighten ToolbarShell padding. - Add a tooltip to the toolbar's Add Color button to match the sibling buttons. Notes - Adding a note creates an empty, immediately-editable note (autofocusing inline textarea; commit on blur/Enter, Shift+Enter newline, Esc reverts); double-click re-enters edit mode — no more stuck "New note". - Pad the note's top so the selection check no longer overlaps the first line. Selection - Clicks accumulate: clicking an item adds it to the selection (clicking again removes it), so multi-select builds up without a modifier; background click clears. "Use as reference" only shows when the selection has a reference-capable image. Item inspector - Redesign ItemInspector to mirror the shared LightboxModal: a bounded, centered, draggable/resizable Modal with media on a dark plate, hover nav arrows, and a fixed info/actions column using shared Buttons. Webapp homepage - Add a Moodboard card next to Edit Video on the webapp home page.
This commit is contained in:
1 parent
43de77f2a6
commit
8334e43886
15 files changed
+477
-207
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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 <PageScene sceneToken={sceneToken} />;
|
||||
case "APPS":
|
||||
return <div><AppsIndexPage /></div>;
|
||||
return (
|
||||
<div>
|
||||
<AppsIndexPage />
|
||||
</div>
|
||||
);
|
||||
case "2D":
|
||||
return <div><PageDrawWithGalleryDrop /></div>;
|
||||
return (
|
||||
<div>
|
||||
<PageDrawWithGalleryDrop />
|
||||
</div>
|
||||
);
|
||||
case "IMAGE":
|
||||
return (
|
||||
<div>
|
||||
@@ -237,29 +242,83 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
</div>
|
||||
);
|
||||
case "VIDEO":
|
||||
return <div><ImageToVideo /></div>;
|
||||
return (
|
||||
<div>
|
||||
<ImageToVideo />
|
||||
</div>
|
||||
);
|
||||
case "VIDEO_FRAME_EXTRACTOR":
|
||||
return <div><VideoFrameExtractor /></div>;
|
||||
return (
|
||||
<div>
|
||||
<VideoFrameExtractor />
|
||||
</div>
|
||||
);
|
||||
case "VIDEO_WATERMARK_REMOVAL":
|
||||
return <div><VideoWatermarkRemover /></div>;
|
||||
return (
|
||||
<div>
|
||||
<VideoWatermarkRemover />
|
||||
</div>
|
||||
);
|
||||
case "IMAGE_WATERMARK_REMOVAL":
|
||||
return <div><ImageWatermarkRemover /></div>;
|
||||
return (
|
||||
<div>
|
||||
<ImageWatermarkRemover />
|
||||
</div>
|
||||
);
|
||||
case "IMAGE_TO_3D_OBJECT":
|
||||
return <div><ImageTo3DObject /></div>;
|
||||
return (
|
||||
<div>
|
||||
<ImageTo3DObject />
|
||||
</div>
|
||||
);
|
||||
case "IMAGE_TO_3D_WORLD":
|
||||
return <div><ImageTo3DWorld /></div>;
|
||||
return (
|
||||
<div>
|
||||
<ImageTo3DWorld />
|
||||
</div>
|
||||
);
|
||||
case "REMOVE_BACKGROUND":
|
||||
return <div><RemoveBackground /></div>;
|
||||
return (
|
||||
<div>
|
||||
<RemoveBackground />
|
||||
</div>
|
||||
);
|
||||
case "ANGLES":
|
||||
return <div><Angles /></div>;
|
||||
return (
|
||||
<div>
|
||||
<Angles />
|
||||
</div>
|
||||
);
|
||||
case "STORYBOARD":
|
||||
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
|
||||
return storyboardPageEnabled ? (
|
||||
<div>
|
||||
<Storyboard />
|
||||
</div>
|
||||
) : null;
|
||||
case "BACKGROUND_CHANGE":
|
||||
return <div><PageBackgroundChange /></div>;
|
||||
return (
|
||||
<div>
|
||||
<PageBackgroundChange />
|
||||
</div>
|
||||
);
|
||||
case "VIDEO_EDITOR":
|
||||
return <div className="h-[calc(100vh-3rem)] w-full"><PageVideoEditor /></div>;
|
||||
return (
|
||||
<div className="h-[calc(100vh-3rem)] w-full">
|
||||
<PageVideoEditor />
|
||||
</div>
|
||||
);
|
||||
case "MOODBOARD":
|
||||
return <div className="h-[calc(100vh-3rem)] w-full"><PageMoodboard /></div>;
|
||||
// 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 (
|
||||
<div className="h-[calc(100vh-56px)] w-full overflow-hidden">
|
||||
<PageMoodboard />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
|
||||
return (
|
||||
<div
|
||||
data-moodboard-root
|
||||
className="relative h-full w-full overflow-hidden bg-ui-background"
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
// Desktop themes set --st-bg (gray/light/black/aurora) so the board follows
|
||||
// the active theme. The webapp never defines --st-bg, so it falls back to
|
||||
// its own --background token (0 0% 9% / #171717), matching the rest of the app.
|
||||
style={{ background: "var(--st-bg, hsl(0 0% 9%))" }}
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<BoardGridView active={!presenting} adapter={adapter} />
|
||||
@@ -77,7 +81,7 @@ const ViewSwitch = ({
|
||||
mode: ViewMode;
|
||||
onChange: (m: ViewMode) => void;
|
||||
}) => (
|
||||
<div className="glass absolute left-3 top-3 z-40 flex items-center gap-0.5 rounded-full border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
|
||||
<div className="glass absolute left-3 top-3 z-40 flex items-center gap-0.5 rounded-2xl border border-ui-divider p-1 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
|
||||
{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;
|
||||
}) => (
|
||||
<div className="pointer-events-none absolute right-3 top-3 z-40 flex items-center gap-2">
|
||||
<div className="glass pointer-events-auto flex items-center rounded-full border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
|
||||
<div className="glass pointer-events-auto flex items-center rounded-xl border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPresent}
|
||||
title="Present"
|
||||
aria-label="Present"
|
||||
className="flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
className="flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlay} className="h-3 w-3" />
|
||||
Present
|
||||
|
||||
@@ -86,7 +86,9 @@ interface BoardLibraryState extends PersistedLibrary {
|
||||
|
||||
// Selection (operates on the active board)
|
||||
setSelection: (ids: Iterable<string>) => 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<BoardLibraryState>()(
|
||||
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<string>();
|
||||
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 };
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<BoardItem["kind"], string> = {
|
||||
@@ -60,6 +66,9 @@ const BoardCardInner = ({
|
||||
onOpen,
|
||||
onDelete,
|
||||
onUseReference,
|
||||
editing = false,
|
||||
onEditText,
|
||||
onCommitText,
|
||||
}: Props) => {
|
||||
const rootRef = useRef<HTMLDivElement | null>(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. */}
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[14px] bg-ui-panel shadow-[inset_0_1px_1px_rgba(255,255,255,0.08)]">
|
||||
<CardContent item={item} />
|
||||
<CardContent
|
||||
item={item}
|
||||
editing={editing}
|
||||
onCommitText={onCommitText}
|
||||
/>
|
||||
|
||||
{/* Rating — persistent when set (Lightroom-style triage marker).
|
||||
No backdrop-blur: this tile lives in a virtualized scroll
|
||||
@@ -211,7 +228,15 @@ const RailButton = ({
|
||||
</button>
|
||||
);
|
||||
|
||||
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 }) => {
|
||||
</div>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden p-4">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-base-fg/90">
|
||||
return editing && onCommitText ? (
|
||||
<NoteEditor
|
||||
initial={item.text}
|
||||
onCommit={(text) => onCommitText(item.id, text)}
|
||||
/>
|
||||
) : (
|
||||
// Extra top padding clears the selection check (top-left) so it never
|
||||
// overlaps the first line of the note.
|
||||
<div className="h-full w-full overflow-hidden px-4 pb-4 pt-10">
|
||||
<p
|
||||
// Clicking the text itself shouldn't toggle the card's selection —
|
||||
// stop the pointer-down from reaching the card root. Double-click
|
||||
// (a separate event) still bubbles, so it opens the editor.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="whitespace-pre-wrap text-sm leading-relaxed text-base-fg/90"
|
||||
>
|
||||
{item.text || "Note"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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<HTMLTextAreaElement | null>(null);
|
||||
const [draft, setDraft] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.select();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
// Matches the static note: top padding clears the selection check.
|
||||
className="h-full w-full px-4 pb-4 pt-10"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => onCommit(draft.trim())}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onCommit(draft.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCommit(initial.trim());
|
||||
}
|
||||
}}
|
||||
placeholder="Write a note…"
|
||||
className="h-full w-full resize-none bg-transparent text-sm leading-relaxed text-base-fg/90 placeholder:text-base-fg/35 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BoardCard = memo(BoardCardInner);
|
||||
@@ -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) => {
|
||||
</p>
|
||||
|
||||
<div className="mt-7 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onUpload}
|
||||
className="group flex items-center gap-2.5 rounded-full bg-primary py-2.5 pl-5 pr-2.5 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary/90 active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
icon={faArrowUpFromBracket}
|
||||
className={CTA_CLASS}
|
||||
>
|
||||
Upload
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:scale-105">
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpFromBracket}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onLibrary}
|
||||
className="flex items-center gap-2 rounded-full border border-ui-divider px-5 py-2.5 text-sm font-medium text-base-fg/80 transition-colors duration-200 hover:bg-base-fg/5 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
icon={faImages}
|
||||
className={CTA_CLASS}
|
||||
>
|
||||
<FontAwesomeIcon icon={faImages} className="h-3.5 w-3.5" />
|
||||
From library
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<GridDensity, string> = {
|
||||
// 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 = ({
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 pt-3">
|
||||
<ToolbarShell className="max-w-full">
|
||||
<div className="flex min-w-0 flex-col pl-1 pr-2">
|
||||
<span className="truncate text-sm font-semibold leading-tight text-base-fg">
|
||||
{boardName}
|
||||
</span>
|
||||
<span className="text-[11px] leading-tight text-base-fg/45">
|
||||
<BoardTitle name={boardName} onRename={onRenameBoard} />
|
||||
<span className="pl-1.5 text-[11px] leading-tight text-base-fg/45">
|
||||
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -179,3 +180,60 @@ export const BoardGridToolbar = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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<HTMLInputElement | null>(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 (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => 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 (
|
||||
<button
|
||||
type="button"
|
||||
title="Rename board"
|
||||
onClick={() => {
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -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<string | null>(null);
|
||||
// Which text card is in inline-edit mode (e.g. a freshly-added note).
|
||||
const [editingTextId, setEditingTextId] = useState<string | null>(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 (
|
||||
<div
|
||||
className="relative h-full w-full bg-ui-background"
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
// Matches MoodboardWorkspace: desktop themes drive --st-bg, the webapp
|
||||
// falls back to its --background token (#171717). overflow-hidden keeps
|
||||
// the view root from ever scrolling — the grid scrolls internally.
|
||||
style={{ background: "var(--st-bg, hsl(0 0% 9%))" }}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault();
|
||||
@@ -206,6 +237,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
|
||||
>
|
||||
<BoardGridToolbar
|
||||
boardName={board?.name ?? "Moodboard"}
|
||||
onRenameBoard={(name) => 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) => {
|
||||
|
||||
<SelectionBar
|
||||
count={selectedItemIds.size}
|
||||
canUseReference={canUseReference}
|
||||
sections={board?.sections ?? []}
|
||||
onUseReference={referenceSelected}
|
||||
onAssignToSection={handleAssignToSection}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPalette } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
|
||||
const DEFAULT_COLOR = "#6384F4"; // character-selected — seeds the picker
|
||||
|
||||
@@ -15,23 +16,32 @@ interface Props {
|
||||
// board until the explicit "Add color" button — so dragging through the spectrum
|
||||
// no longer spams the board with a swatch on every tick.
|
||||
export const ColorPickerPopover = ({ onAdd }: Props) => (
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
position="bottom"
|
||||
panelTitle="Add a color"
|
||||
panelClassName="w-60"
|
||||
triggerIcon={<FontAwesomeIcon icon={faPalette} className="h-4 w-4" />}
|
||||
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) => (
|
||||
<ColorPickerPanel
|
||||
onAdd={(color) => {
|
||||
onAdd(color);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverMenu>
|
||||
// Tooltip matches the sibling ToolbarIconButtons (bottom, 300ms, close on click).
|
||||
<Tooltip content="Add color" position="bottom" delay={300} closeOnClick>
|
||||
<PopoverMenu
|
||||
mode="default"
|
||||
position="bottom"
|
||||
panelTitle="Add a color"
|
||||
panelClassName="w-60"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={faPalette}
|
||||
className="h-4 w-4"
|
||||
aria-label="Add color"
|
||||
/>
|
||||
}
|
||||
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) => (
|
||||
<ColorPickerPanel
|
||||
onAdd={(color) => {
|
||||
onAdd(color);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverMenu>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const ColorPickerPanel = ({ onAdd }: Props) => {
|
||||
|
||||
@@ -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<BoardItem["kind"], string> = {
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Item details"
|
||||
className="fixed inset-0 z-50 flex bg-black/80 backdrop-blur-md"
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
accessibleTitle="Item details"
|
||||
className="rounded-xl bg-ui-modal h-[760px] w-[1200px] max-w-screen min-w-[1000px] min-h-[600px] p-4"
|
||||
draggable
|
||||
resizable
|
||||
expandable
|
||||
showClose
|
||||
>
|
||||
{/* Backdrop click closes */}
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
{/* Invisible drag strip along the top, matching the shared lightbox. */}
|
||||
<Modal.DragHandle>
|
||||
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
|
||||
</Modal.DragHandle>
|
||||
|
||||
<NavArrow side="left" show={hasPrev} onClick={onPrev} />
|
||||
<NavArrow side="right" show={hasNext} onClick={onNext} />
|
||||
|
||||
<div className="relative z-10 flex w-full items-stretch gap-4 p-6">
|
||||
{/* Media */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center">
|
||||
<div className="flex h-full gap-4">
|
||||
{/* Media panel — flexible width, dark plate, contained media. */}
|
||||
<div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-l-xl bg-black/30">
|
||||
<Media item={item} />
|
||||
|
||||
{hasPrev && <NavArrow side="left" onClick={onPrev} />}
|
||||
{hasNext && <NavArrow side="right" onClick={onNext} />}
|
||||
</div>
|
||||
|
||||
{/* Inspector panel — double-bezel */}
|
||||
<div className="w-[320px] shrink-0 rounded-[20px] bg-[var(--mb-plane-1,rgba(127,127,127,0.05))] p-1.5 ring-1 ring-[var(--mb-hairline,rgba(127,127,127,0.14))]">
|
||||
<div className="flex h-full flex-col rounded-[14px] bg-ui-panel p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="rounded-full border border-ui-divider px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-base-fg/55">
|
||||
{/* Info + actions — fixed width, scrollable body, buttons pinned bottom. */}
|
||||
<div className="flex h-full w-[280px] shrink-0 flex-col">
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto pb-2 text-base-fg">
|
||||
<div className="flex items-center gap-2.5 border-b border-white/5 pb-3 pr-10">
|
||||
<span className="rounded-full border border-ui-panel-border px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-base-fg/55">
|
||||
{KIND_LABEL[item.kind]}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<Section title="Rating">
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
@@ -158,7 +149,6 @@ export const ItemInspector = ({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Tags */}
|
||||
<Section title="Tags">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{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}
|
||||
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
|
||||
@@ -185,7 +175,6 @@ export const ItemInspector = ({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Palette */}
|
||||
{item.kind === "image" && palette.length > 0 && (
|
||||
<Section title="Palette">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -213,39 +202,32 @@ export const ItemInspector = ({
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{item.kind === "image" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseReference}
|
||||
className="group flex items-center justify-center gap-2.5 rounded-full bg-primary py-2 pl-5 pr-2 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary/90 active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
Use as reference
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:scale-105">
|
||||
<FontAwesomeIcon
|
||||
icon={faWandMagicSparkles}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="flex items-center justify-center gap-2 rounded-full border border-ui-divider py-2.5 text-sm font-medium text-base-fg/70 transition-colors hover:bg-danger/10 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
|
||||
{/* Actions — shared Button, lightbox-style 2-col grid. */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-1.5">
|
||||
{item.kind === "image" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={faWandMagicSparkles}
|
||||
onClick={onUseReference}
|
||||
className="w-full col-span-2 py-1.5 text-[13px]"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
Use as reference
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
icon={faTrashCan}
|
||||
onClick={onDelete}
|
||||
className="w-full col-span-2 py-1.5 text-[13px]"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -256,10 +238,8 @@ const Section = ({
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-[0.18em] text-base-fg/45">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-base-fg/90">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -271,7 +251,7 @@ const Media = ({ item }: { item: BoardItem }) => {
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.caption || ""}
|
||||
className="max-h-full max-w-full rounded-xl object-contain shadow-2xl"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={side === "left" ? "Previous" : "Next"}
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"absolute top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full",
|
||||
"border border-white/15 bg-black/40 text-white/85 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
|
||||
side === "left" ? "left-4" : "right-4",
|
||||
].join(" ")}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={side === "left" ? faChevronLeft : faChevronRight}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={side === "left" ? "Previous" : "Next"}
|
||||
onClick={(e) => {
|
||||
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(" ")}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={side === "left" ? faChevronLeft : faChevronRight}
|
||||
className="text-lg"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-30 flex justify-center">
|
||||
<div className="glass pointer-events-auto flex items-center gap-2 rounded-full border border-ui-divider py-1.5 pl-4 pr-1.5 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.5)]">
|
||||
<span className="text-sm font-medium text-base-fg">
|
||||
{/* Styled to match the webapp library's multiselect bar: solid pill, filled
|
||||
control buttons, red delete, round clear. */}
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-ui-panel-border bg-ui-panel/95 px-2.5 py-2 shadow-xl backdrop-blur">
|
||||
<span className="px-1 text-sm font-medium text-white/80">
|
||||
{count} selected
|
||||
</span>
|
||||
{/* Triage shortcut discoverability — these keys act on the selection. */}
|
||||
<span className="hidden items-center gap-1 text-[11px] text-base-fg/45 sm:flex">
|
||||
<span className="hidden items-center gap-1 text-[11px] text-white/40 sm:flex">
|
||||
<Kbd>1–5</Kbd> rate
|
||||
<Kbd>0</Kbd> clear
|
||||
</span>
|
||||
<div className="mx-1 h-5 w-px bg-ui-divider" />
|
||||
<MoveToMenu
|
||||
sections={sections}
|
||||
onAssignToSection={onAssignToSection}
|
||||
onCreateSectionWithSelection={onCreateSectionWithSelection}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseReference}
|
||||
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-primary/15 hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWandMagicSparkles} className="h-3.5 w-3.5" />
|
||||
Use as reference
|
||||
</button>
|
||||
{canUseReference && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseReference}
|
||||
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"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWandMagicSparkles} className="text-xs" />
|
||||
Use as reference
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
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-danger/15 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
|
||||
className="flex items-center gap-2 rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red focus:outline-none focus-visible:ring-2 focus-visible:ring-red"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5" />
|
||||
<FontAwesomeIcon icon={faTrashCan} className="text-xs" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
onClick={onClear}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 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 h-8 w-8 items-center justify-center rounded-full bg-ui-controls/60 text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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"
|
||||
>
|
||||
<FontAwesomeIcon icon={faLayerGroup} className="h-3.5 w-3.5" />
|
||||
<FontAwesomeIcon icon={faLayerGroup} className="text-xs" />
|
||||
Move to
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-[calc(100%+8px)] left-1/2 z-40 max-h-72 w-52 -translate-x-1/2 overflow-y-auto rounded-2xl border border-ui-divider bg-ui-panel/95 p-1.5 shadow-[0_16px_40px_-12px_rgba(0,0,0,0.55)] backdrop-blur-xl"
|
||||
className="absolute bottom-[calc(100%+8px)] left-1/2 z-40 max-h-72 w-52 -translate-x-1/2 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl"
|
||||
>
|
||||
<MenuItem
|
||||
label="Ungrouped"
|
||||
@@ -134,7 +141,7 @@ const MoveToMenu = ({
|
||||
onClick={() => pick(() => onAssignToSection(null))}
|
||||
/>
|
||||
{sections.length > 0 && (
|
||||
<div className="my-1 h-px bg-ui-divider/70" />
|
||||
<div className="mx-1.5 my-1 border-t border-ui-panel-border" />
|
||||
)}
|
||||
{sections.map((s) => (
|
||||
<MenuItem
|
||||
@@ -143,7 +150,7 @@ const MoveToMenu = ({
|
||||
onClick={() => pick(() => onAssignToSection(s.id))}
|
||||
/>
|
||||
))}
|
||||
<div className="my-1 h-px bg-ui-divider/70" />
|
||||
<div className="mx-1.5 my-1 border-t border-ui-panel-border" />
|
||||
<MenuItem
|
||||
label="New section"
|
||||
icon={faPlus}
|
||||
@@ -174,13 +181,13 @@ const MenuItem = ({
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left text-sm transition-colors duration-150",
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
accent
|
||||
? "text-primary hover:bg-primary/10"
|
||||
? "text-primary hover:bg-ui-controls/50"
|
||||
: muted
|
||||
? "text-base-fg/55 hover:bg-base-fg/10 hover:text-base-fg"
|
||||
: "text-base-fg/85 hover:bg-base-fg/10",
|
||||
? "text-white/50 hover:bg-ui-controls/50 hover:text-white"
|
||||
: "text-white hover:bg-ui-controls/50",
|
||||
].join(" ")}
|
||||
>
|
||||
{icon ? (
|
||||
@@ -193,7 +200,7 @@ const MenuItem = ({
|
||||
);
|
||||
|
||||
const Kbd = ({ children }: { children: ReactNode }) => (
|
||||
<kbd className="rounded border border-ui-divider bg-base-fg/5 px-1 py-px font-sans text-[10px] text-base-fg/70">
|
||||
<kbd className="rounded border border-ui-panel-border bg-white/5 px-1 py-px font-sans text-[10px] text-white/60">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="glass flex h-9 min-w-[200px] items-center gap-2 rounded-full border border-ui-divider px-3 focus-within:ring-2 focus-within:ring-primary/50">
|
||||
<div className="bg-ui-controls flex h-9 min-w-[200px] transition-all items-center gap-2 rounded-xl border border-ui-controls px-3 focus-within:ring-2 focus-within:ring-primary/50">
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className="h-3.5 w-3.5 text-base-fg/45"
|
||||
|
||||
@@ -17,7 +17,7 @@ interface ToolbarShellProps {
|
||||
export const ToolbarShell = ({ children, className }: ToolbarShellProps) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"glass pointer-events-auto flex items-center gap-2 rounded-2xl border border-ui-divider px-2 py-1.5 text-base-fg shadow-[0_8px_28px_-12px_rgba(0,0,0,0.45)]",
|
||||
"glass pointer-events-auto flex items-center gap-2 rounded-2xl border border-ui-divider p-2 text-base-fg shadow-[0_8px_28px_-12px_rgba(0,0,0,0.45)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user