folders in gallery modal on desktop

This commit is contained in:
bflatastic committed 2026-06-12 18:24:33 +07:00
1 parent 2818831a66
commit 9950dfebf0
5 files changed
+167 -64

No files matched your search

@@ -608,6 +608,13 @@ export const GalleryModal = React.memo(
[folders, activeFolderId],
);
// Root-level folders for the filter sidebar — always roots, regardless of
// which folder is open in the browser tab (mirrors the webapp sidebar).
const rootFolders = useMemo(
() => folders.filter((f) => !f.parentId).sort(compareFolders),
[folders],
);
// Breadcrumb trail from root → active folder (inclusive), guarded against
// cycles so a corrupted parent chain can never loop forever.
const folderPath = useMemo(() => {
@@ -1280,6 +1287,16 @@ export const GalleryModal = React.memo(
setContextMenu(null);
}, []);
// Open a folder from the filter sidebar: jump to the folder browser tab
// with that folder active.
const handleOpenFolderFromSidebar = useCallback(
(folderId: string) => {
setGalleryTab("folders");
handleOpenFolder(folderId);
},
[handleOpenFolder],
);
// Close any open folder menus
const closeFolderMenus = useCallback(() => {
setFolderMenuOpen(false);
@@ -2170,46 +2187,109 @@ export const GalleryModal = React.memo(
{/* ── Filter sidebar ── (Unsorted tab / picker only; the Folders
tab is a full-width browser) */}
{!hideFilter && galleryTab === "unsorted" && (
<div className="w-52 min-w-[13rem] border-r border-ui-panel-border bg-ui-background flex flex-col overflow-y-auto">
{/* Filter items — hidden when the gallery is used as a constrained picker */}
{!hideFilter && (
<>
<div className="flex flex-col px-1.5 pt-2 pb-1">
{SIDEBAR_FILTERS.map((f) => {
const isActive = activeFilter === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => {
if (!forceFilter) {
setActiveFilter(f.id);
setActiveFolderId(null);
}
}}
className={twMerge(
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
isActive && !activeFolderId
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
forceFilter &&
f.id !== activeFilter &&
"opacity-50 pointer-events-none",
)}
>
<div className="flex items-center gap-2.5">
<FontAwesomeIcon
icon={f.icon}
className="text-xs w-4"
/>
<span>{f.label}</span>
</div>
</button>
);
})}
<div className="w-52 min-w-[13rem] border-r border-ui-panel-border bg-ui-panel flex flex-col overflow-hidden">
{/* Filter items — pinned; they never scroll out of view */}
<div className="flex flex-col px-1.5 pt-2 pb-1 flex-shrink-0">
{SIDEBAR_FILTERS.map((f) => {
const isActive = activeFilter === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => {
if (!forceFilter) {
setActiveFilter(f.id);
setActiveFolderId(null);
}
}}
className={twMerge(
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
isActive && !activeFolderId
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
forceFilter &&
f.id !== activeFilter &&
"opacity-50 pointer-events-none",
)}
>
<div className="flex items-center gap-2.5">
<FontAwesomeIcon
icon={f.icon}
className="text-xs w-4"
/>
<span>{f.label}</span>
</div>
</button>
);
})}
</div>
{/* Folders header — pinned alongside the filters */}
<div className="flex items-center justify-between px-3 pt-2 pb-1 flex-shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-base-fg/40">
Folders
</span>
{mode === "view" && (
<button
type="button"
onClick={() => handleOpenNewFolderModal(null)}
aria-label="New folder"
className="flex h-5 w-5 items-center justify-center rounded text-base-fg/50 hover:bg-ui-controls/60 hover:text-base-fg transition-colors"
>
<FontAwesomeIcon icon={faPlus} className="text-[10px]" />
</button>
)}
</div>
{/* Folder list — the only scrolling region; fills the rest of the column */}
<div className="flex flex-1 min-h-0 flex-col overflow-y-auto px-1.5 pb-2">
{rootFolders.length === 0 ? (
<div className="px-2.5 py-1.5 text-xs text-base-fg/30 italic">
No folders yet
</div>
</>
)}
) : (
rootFolders.map((folder) => (
<button
key={folder.id}
type="button"
data-folder-id={folder.id}
onClick={() => handleOpenFolderFromSidebar(folder.id)}
onContextMenu={
// Folder management is view-mode only; the select picker just browses.
mode === "view"
? (e) => {
e.preventDefault();
setContextMenu({
folderId: folder.id,
x: e.clientX,
y: e.clientY,
});
}
: undefined
}
className="flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg transition-colors [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-base-fg"
>
<FontAwesomeIcon
icon={faFolder}
className={twMerge(
"text-xs w-4",
!folder.colorCode && "text-primary",
)}
style={
folder.colorCode
? { color: folder.colorCode }
: undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</button>
))
)}
</div>
</div>
)}
@@ -18,7 +18,11 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { Prompt2DStore, RefImage, useEnterToGenerateStore } from "./promptStore";
import {
Prompt2DStore,
RefImage,
useEnterToGenerateStore,
} from "./promptStore";
import { ImageModel } from "@storyteller/model-list";
import { ImagePromptRow, UploadImageFn } from "./ImagePromptRow";
import { twMerge } from "tailwind-merge";
@@ -376,7 +380,7 @@ export const PromptBox2D = ({
)}
<div
className={twMerge(
"glass relative w-[860px] rounded-xl p-4",
"glass relative w-[860px] rounded-2xl p-4",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
@@ -41,7 +41,11 @@ import {
} from "@storyteller/tauri-api";
import { usePrompt3DStore, useEnterToGenerateStore } from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { CommonAspectRatio, CommonResolution, ImageModel } from "@storyteller/model-list";
import {
CommonAspectRatio,
CommonResolution,
ImageModel,
} from "@storyteller/model-list";
import { GenerationProvider } from "@storyteller/api-enums";
import { ImagePromptRow } from "./ImagePromptRow";
import { AspectRatioPicker } from "./common/AspectRatioPicker";
@@ -69,11 +73,11 @@ interface PromptBox3DProps {
selectedImageModel?: ImageModel;
selectedProvider?: GenerationProvider;
snapshotCurrentFrame:
| ((shouldDownload?: boolean) => {
base64Snapshot: string;
file: File;
} | null)
| undefined;
| ((shouldDownload?: boolean) => {
base64Snapshot: string;
file: File;
} | null)
| undefined;
credits?: number | null;
/** Optional model-picker slot rendered at the start of the toolbar
* (left of the aspect-ratio picker). Tauri leaves this unset and
@@ -298,7 +302,9 @@ export const PromptBox3D = ({
return;
}
if (isFinite(maxLen) && prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
toast.error(
`Prompt exceeds the ${maxLen} character limit for this model`,
);
return;
}
gtagEvent("enqueue_3d");
@@ -479,8 +485,7 @@ export const PromptBox3D = ({
?.map((image) => image.url)
.filter(Boolean),
modelType:
(selectedImageModel as any)?.tauriId ||
String(selectedImageModel),
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
timestamp: Date.now(),
});
@@ -628,11 +633,11 @@ export const PromptBox3D = ({
)}
<div
className={twMerge(
"glass relative w-[860px] rounded-xl p-4",
"glass relative w-[860px] rounded-2xl p-4",
isPromptBoxFocused ? "!border !border-primary" : "",
selectedImageModel?.canUseImagePrompt &&
isImageRowVisible &&
"rounded-t-none",
isImageRowVisible &&
"rounded-t-none",
)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@@ -693,7 +698,9 @@ export const PromptBox3D = ({
setIsPromptBoxFocused(false);
}}
/>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
<span
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${isFinite(maxLen) && prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
>
{prompt.length} / {isFinite(maxLen) ? maxLen : "∞"}
</span>
</div>
@@ -836,13 +843,20 @@ export const PromptBox3D = ({
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<Tooltip
content={isExpanded ? "Collapse" : "Expand"}
position="top"
className="-mb-2"
>
<button
type="button"
onClick={toggleExpand}
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-xs"
/>
</button>
</Tooltip>
</div>
@@ -6,10 +6,7 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button, ToggleButton, GenerateButton } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import {
GenerateImage,
GenerateImageRequest,
} from "@storyteller/tauri-api";
import { GenerateImage, GenerateImageRequest } from "@storyteller/tauri-api";
import {
faMessageXmark,
faMessageCheck,
@@ -19,7 +16,11 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CommonAspectRatio, ImageModel } from "@storyteller/model-list";
import { usePromptImageStore, RefImage, useEnterToGenerateStore } from "./promptStore";
import {
usePromptImageStore,
RefImage,
useEnterToGenerateStore,
} from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { twMerge } from "tailwind-merge";
import { ImagePromptRow } from "./ImagePromptRow";
@@ -68,7 +69,11 @@ export const PromptBoxImage = ({
}: PromptBoxImageProps) => {
useSignals();
console.debug("Selected model and provider:", selectedModel, selectedProvider);
console.debug(
"Selected model and provider:",
selectedModel,
selectedProvider,
);
useEffect(() => {
if (imageMediaId && url) {
@@ -414,7 +419,7 @@ export const PromptBoxImage = ({
<div
className={twMerge(
"glass relative w-[860px] rounded-xl p-4",
"glass relative w-[860px] rounded-2xl p-4",
isImageRowVisible &&
selectedModel?.canUseImagePrompt &&
"rounded-t-none",
@@ -997,7 +997,7 @@ export const PromptBoxVideo = ({
)}
<div
className={twMerge(
"glass relative w-[860px] rounded-xl p-4",
"glass relative w-[860px] rounded-2xl p-4",
isImageRowVisible && "rounded-t-none",
isFocused
? "ring-1 ring-primary border-primary"