mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 19:41:32 +00:00
folders in gallery modal on desktop
This commit is contained in:
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"
|
||||
|
||||
Reference in new issue
Block a user