Compare commits

...
Author SHA1 Message Date
BFlat aa5fad440f reuse gallery modal for the artcraft website and app styling aligned 2026-04-22 17:10:17 +07:00
32 changed files with 930 additions and 2620 deletions
@@ -40,8 +40,9 @@ const getLabel = (item: any) => {
export function useGalleryData(options: {
username: string | null;
filterMediaClasses: FilterMediaClasses[];
excludeUploads?: boolean;
}) {
const { username, filterMediaClasses } = options;
const { username, filterMediaClasses, excludeUploads } = options;
const [items, setItems] = useState<GalleryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -83,7 +84,7 @@ export function useGalleryData(options: {
const response = await api.listUserMediaFiles({
username,
filter_media_classes: filterMediaClasses,
include_user_uploads: true,
include_user_uploads: !excludeUploads,
page_index: reset ? 0 : pageIndex,
page_size: PAGE_SIZE,
});
@@ -91,7 +92,9 @@ export function useGalleryData(options: {
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
!(excludeUploads && item.origin_category === "upload"),
)
.map(mapApiItem);
@@ -114,7 +117,7 @@ export function useGalleryData(options: {
setIsInitialLoading(false);
isLoadingRef.current = false;
},
[username, filterMediaClasses, pageIndex, api, mapApiItem],
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
);
// Initial load + filter change
@@ -1,241 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import {
UsersApi,
GalleryModalApi,
FilterMediaClasses,
} from "@storyteller/api";
import { getThumbnailUrl, THUMBNAIL_SIZES } from "@storyteller/common";
interface LibraryImage {
token: string;
url: string;
thumbnailUrl: string;
}
interface ImagePickerModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (images: LibraryImage[]) => void;
maxSelect: number;
}
const PAGE_SIZE = 60;
export const ImagePickerModal = ({
isOpen,
onClose,
onSelect,
maxSelect,
}: ImagePickerModalProps) => {
const [username, setUsername] = useState<string | null>(null);
const [images, setImages] = useState<LibraryImage[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(true);
const isLoadingRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const api = useMemo(() => new GalleryModalApi(), []);
// Auth check
useEffect(() => {
if (!isOpen) return;
const checkSession = async () => {
const usersApi = new UsersApi();
const response = await usersApi.GetSession();
if (response.success && response.data?.loggedIn && response.data.user) {
setUsername(response.data.user.username);
}
};
checkSession();
}, [isOpen]);
// Load images
const loadImages = useCallback(
async (reset = false) => {
if (!username || isLoadingRef.current) return;
isLoadingRef.current = true;
setLoading(true);
try {
const response = await api.listUserMediaFiles({
username,
filter_media_classes: [FilterMediaClasses.IMAGE],
include_user_uploads: true,
page_index: reset ? 0 : pageIndex,
page_size: PAGE_SIZE,
});
if (response.success && response.data) {
const newImages: LibraryImage[] = response.data
.filter((item: any) => item.media_class === "image")
.map((item: any) => ({
token: item.token,
url: item.media_links?.cdn_url ?? "",
thumbnailUrl:
getThumbnailUrl(item.media_links?.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.MEDIUM,
}) ??
item.media_links?.cdn_url ??
"",
}))
.filter((img: LibraryImage) => img.url);
if (reset) {
setImages(newImages);
} else {
setImages((prev) => [...prev, ...newImages]);
}
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
}
} catch {
// ignore
}
setLoading(false);
isLoadingRef.current = false;
},
[username, pageIndex, api],
);
// Initial load when opened
useEffect(() => {
if (!username || !isOpen) return;
setImages([]);
setSelected(new Set());
setPageIndex(0);
setHasMore(true);
isLoadingRef.current = false;
loadImages(true);
}, [username, isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// Infinite scroll
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const handleScroll = () => {
const scrollBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (scrollBottom < 300 && hasMore && !isLoadingRef.current) {
loadImages();
}
};
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [hasMore, loadImages]);
const toggleSelect = (token: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(token)) {
next.delete(token);
} else if (next.size < maxSelect) {
next.add(token);
}
return next;
});
};
const handleConfirm = () => {
const selectedImages = images.filter((img) => selected.has(img.token));
onSelect(selectedImages);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Pick from Library"
showClose
className="max-w-7xl"
>
<div className="flex flex-col" style={{ height: "min(80vh, 800px)" }}>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{images.length === 0 && loading ? (
<div className="flex h-full items-center justify-center">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-2xl text-white/40"
/>
</div>
) : images.length === 0 ? (
<div className="flex h-full items-center justify-center text-white/40 text-sm">
No images found
</div>
) : (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 pt-1">
{images.map((img) => {
const isSelected = selected.has(img.token);
return (
<button
key={img.token}
onClick={() => toggleSelect(img.token)}
className={`group relative aspect-square overflow-hidden rounded-lg transition-all ${
isSelected
? "ring-2 ring-primary-400 ring-offset-2 ring-offset-[#1a1a20]"
: "hover:ring-2 hover:ring-white/30"
}`}
>
<img
src={img.thumbnailUrl}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
{isSelected && (
<div className="absolute inset-0 flex items-center justify-center bg-primary-400/20">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary-400 text-white">
<FontAwesomeIcon icon={faCheck} className="h-3 w-3" />
</div>
</div>
)}
</button>
);
})}
</div>
)}
{loading && images.length > 0 && (
<div className="flex justify-center py-4">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg text-white/40"
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3">
<span className="text-sm text-white/50">
{selected.size} of {maxSelect} selected
</span>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={onClose}
className="px-4 py-1.5 text-sm"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleConfirm}
disabled={selected.size === 0}
className="px-4 py-1.5 text-sm"
>
Use selected
</Button>
</div>
</div>
</div>
</Modal>
);
};
@@ -304,7 +304,7 @@ export const ImagePromptRow = ({
interactive
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
closeOnClick
content={
<div className="flex flex-col gap-1.5">
@@ -397,7 +397,7 @@ export const ImagePromptRow = ({
interactive
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
closeOnClick
content={
<div className="flex flex-col gap-1.5">
@@ -384,7 +384,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
<div
className={twMerge(
"glass rounded-xl p-3 sm:p-4 !transition-all duration-200",
hasAnyRowAbove && "rounded-t-none",
hasAnyRowAbove && "rounded-t-none border-t-0",
isFocused && "ring-1 ring-primary",
)}
>
@@ -430,7 +430,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
mentionItems={mentionItems}
placeholder={placeholder}
className={twMerge(
"promptbox-scrollbar min-h-[2.5em] w-full text-white",
"promptbox-scrollbar min-h-[2.5em] w-full text-base-fg placeholder-base-fg/60",
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
)}
colorMap={mentionColorMap}
@@ -450,7 +450,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
ref={highlightRef}
aria-hidden
className={twMerge(
"pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words text-sm text-white",
"pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words text-sm text-base-fg",
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
)}
>
@@ -464,7 +464,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
autoFocus
placeholder={placeholder}
className={twMerge(
"promptbox-scrollbar min-h-[2.5em] w-full flex-1 resize-y overflow-y-auto bg-transparent text-md text-white placeholder-white/50 focus:outline-none",
"promptbox-scrollbar min-h-[2.5em] w-full flex-1 resize-y overflow-y-auto bg-transparent text-md text-base-fg placeholder-base-fg/60 focus:outline-none",
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
hasMentionItems && "text-transparent caret-white",
)}
@@ -477,15 +477,15 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
/>
{mentionOpen && filteredMentionItems.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-ui-panel-border bg-ui-controls shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
Mentions
</div>
{filteredMentionItems.map((item, i) => (
<button
key={item.label}
className={twMerge(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors",
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-base-fg transition-colors",
i === mentionIndex
? "bg-white/10"
: "hover:bg-white/5",
@@ -1,6 +1,5 @@
export { PromptBox } from "./PromptBox";
export { ImagePromptRow } from "./ImagePromptRow";
export { ImagePickerModal } from "./ImagePickerModal";
export { MediaReferenceRow } from "./MediaReferenceRow";
export { CharactersModal } from "./CharactersModal";
export { useCharactersStore } from "./characters-store";
@@ -4,9 +4,9 @@ import { FilterMediaClasses } from "@storyteller/api";
import type { OmniGenImageModelInfo } from "@storyteller/api";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import {
PromptBox,
ImagePickerModal,
type RefImage,
} from "../../components/prompt-box";
import {
@@ -111,6 +111,28 @@ export default function CreateImage() {
const [isGenerating, setIsGenerating] = useState(false);
const [referenceImages, setReferenceImages] = useState<RefImage[]>([]);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
const imagePickerMax = Math.max(
1,
(selectedModel?.image_refs_max ?? 1) - referenceImages.length,
);
useEffect(() => {
if (isImagePickerOpen) setPickerSelectedIds([]);
}, [isImagePickerOpen]);
const handlePickerSelect = useCallback(
(id: string) => {
setPickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= imagePickerMax) {
return imagePickerMax === 1 ? [id] : prev;
}
return [...prev, id];
});
},
[imagePickerMax],
);
// Batch store (enqueue flow only)
const startBatch = useCreateImageStore((s) => s.startBatch);
@@ -124,6 +146,7 @@ export default function CreateImage() {
const gallery = useGalleryData({
username: user?.username ?? null,
filterMediaClasses: IMAGE_FILTER,
excludeUploads: true,
});
const newlyCompletedTokens = useMemo(
@@ -239,18 +262,19 @@ export default function CreateImage() {
);
const handleLibraryImageSelect = useCallback(
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
(items: GalleryItem[]) => {
const maxImages = selectedModel?.image_refs_max ?? 1;
const availableSlots = Math.max(0, maxImages - referenceImages.length);
const newImages: RefImage[] = images
const newImages: RefImage[] = items
.slice(0, availableSlots)
.map((img) => ({
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
url: item.thumbnail || item.fullImage || "",
file: new File([], "library-image"),
mediaToken: img.token,
mediaToken: item.id,
}));
setReferenceImages([...referenceImages, ...newImages]);
setIsImagePickerOpen(false);
},
[referenceImages, selectedModel],
);
@@ -447,14 +471,16 @@ export default function CreateImage() {
}
modals={
<>
<ImagePickerModal
<GalleryModal
mode="select"
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
onSelect={handleLibraryImageSelect}
maxSelect={Math.max(
1,
(selectedModel?.image_refs_max ?? 1) - referenceImages.length,
)}
selectedItemIds={pickerSelectedIds}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
forceFilter="image"
hideFilter
/>
<Lightbox
isOpen={lightbox.lightboxOpen}
@@ -12,9 +12,9 @@ import { ToggleButton } from "@storyteller/ui-button";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { SliderV2 } from "@storyteller/ui-sliderv2";
import { Tooltip } from "@storyteller/ui-tooltip";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import {
PromptBox,
ImagePickerModal,
MediaReferenceRow,
CharactersModal,
useCharactersStore,
@@ -217,6 +217,18 @@ export default function CreateVideo() {
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [isEndFramePickerOpen, setIsEndFramePickerOpen] = useState(false);
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
const [pickerSelectedIds, setPickerSelectedIds] = useState<string[]>([]);
const [endFramePickerSelectedIds, setEndFramePickerSelectedIds] = useState<
string[]
>([]);
useEffect(() => {
if (isImagePickerOpen) setPickerSelectedIds([]);
}, [isImagePickerOpen]);
useEffect(() => {
if (isEndFramePickerOpen) setEndFramePickerSelectedIds([]);
}, [isEndFramePickerOpen]);
// Characters store for @-mentions
const storedCharacters = useCharactersStore((s) => s.characters);
@@ -278,6 +290,7 @@ export default function CreateVideo() {
const gallery = useGalleryData({
username: user?.username ?? null,
filterMediaClasses: VIDEO_FILTER,
excludeUploads: true,
});
const newlyCompletedTokens = useMemo(
@@ -592,38 +605,65 @@ export default function CreateVideo() {
[inputMode, setUi],
);
const imagePickerMax = Math.max(
1,
(isReferenceMode
? (selectedModel?.image_references_max ?? 3)
: 1) - referenceImages.length,
);
const handlePickerSelect = useCallback(
(id: string) => {
setPickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= imagePickerMax) {
return imagePickerMax === 1 ? [id] : prev;
}
return [...prev, id];
});
},
[imagePickerMax],
);
const handleEndFramePickerSelect = useCallback((id: string) => {
setEndFramePickerSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
// Single-select: auto-swap
return [id];
});
}, []);
const handleLibraryImageSelect = useCallback(
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
(items: GalleryItem[]) => {
const maxImages = isReferenceMode
? (selectedModel?.image_references_max ?? 3)
: 1;
const availableSlots = Math.max(0, maxImages - referenceImages.length);
const newImages: RefImage[] = images
const newImages: RefImage[] = items
.slice(0, availableSlots)
.map((img) => ({
.map((item) => ({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
url: item.thumbnail || item.fullImage || "",
file: new File([], "library-image"),
mediaToken: img.token,
mediaToken: item.id,
}));
setReferenceImages([...referenceImages, ...newImages]);
setIsImagePickerOpen(false);
},
[referenceImages, isReferenceMode, selectedModel],
);
const handleEndFrameLibrarySelect = useCallback(
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
const img = images[0];
if (!img) return;
setEndFrameImage({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
file: new File([], "library-image"),
mediaToken: img.token,
});
},
[],
);
const handleEndFrameLibrarySelect = useCallback((items: GalleryItem[]) => {
const item = items[0];
if (!item) return;
setEndFrameImage({
id: Math.random().toString(36).substring(7),
url: item.thumbnail || item.fullImage || "",
file: new File([], "library-image"),
mediaToken: item.id,
});
setIsEndFramePickerOpen(false);
}, []);
const handleGenerate = useCallback(async () => {
if (!prompt.trim() || isGenerating || needsImage || !selectedModel) return;
@@ -1039,22 +1079,27 @@ export default function CreateVideo() {
}
modals={
<>
<ImagePickerModal
<GalleryModal
mode="select"
isOpen={isImagePickerOpen}
onClose={() => setIsImagePickerOpen(false)}
onSelect={handleLibraryImageSelect}
maxSelect={Math.max(
1,
(isReferenceMode
? (selectedModel?.image_references_max ?? 3)
: 1) - referenceImages.length,
)}
selectedItemIds={pickerSelectedIds}
onSelectItem={handlePickerSelect}
maxSelections={imagePickerMax}
onUseSelected={handleLibraryImageSelect}
forceFilter="image"
hideFilter
/>
<ImagePickerModal
<GalleryModal
mode="select"
isOpen={isEndFramePickerOpen}
onClose={() => setIsEndFramePickerOpen(false)}
onSelect={handleEndFrameLibrarySelect}
maxSelect={1}
selectedItemIds={endFramePickerSelectedIds}
onSelectItem={handleEndFramePickerSelect}
maxSelections={1}
onUseSelected={handleEndFrameLibrarySelect}
forceFilter="image"
hideFilter
/>
<CharactersModal
isOpen={isCharactersModalOpen}
@@ -53,7 +53,7 @@
Arial,
sans-serif;
color: #ffffff;
background-color: #1b1b1f;
background-color: #242424;
}
}
@@ -68,7 +68,7 @@
.glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300;
background-color: rgba(46, 46, 49, 0.85) !important;
@apply bg-ui-controls/85 !important;
}
/* Minimal thin scrollbar for prompt textareas */
@@ -132,6 +132,7 @@
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
@@ -62,15 +62,16 @@ module.exports = {
},
"base-fg": "rgb(255 255 255 / <alpha-value>)",
ui: {
// Aligned with artcraft app's theme-gray (frontend/apps/artcraft/app/src/styles/base.css)
background: "#242424",
panel: "#1F1F1F",
"panel-border": "#3F3F3F",
border: "#3F3F3F",
"panel-border": "#272727",
border: "#272727",
controls: "#3E3E41",
"controls-button": "#3E3E41",
"controls-border": "#3f3f3f",
divider: "#515168",
modal: "#1f1f1f",
"controls-border": "#464646",
divider: "#272727",
modal: "#1F1F1F",
},
},
},
@@ -0,0 +1,23 @@
import { MediaFileDelete } from "@storyteller/tauri-api";
import { listen } from "@tauri-apps/api/event";
export const galleryModalDeleteMedia = (id: string) => MediaFileDelete(id);
export const galleryModalSubscribeToMediaEvents = (handlers: {
onGenerationComplete: () => void;
onMediaDeleted: (mediaId: string) => void;
}) => {
const unlistenGen = listen("generation-complete-event", () => {
handlers.onGenerationComplete();
});
const unlistenDel = listen<any>("media_file_deleted_event", (event) => {
const token: string | undefined =
event?.payload?.data?.media_file_token ??
event?.payload?.media_file_token;
if (token) handlers.onMediaDeleted(token);
});
return () => {
unlistenGen.then((fn) => fn());
unlistenDel.then((fn) => fn());
};
};
@@ -2,6 +2,7 @@ import { Fragment, useCallback } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { DoNotShow } from "~/constants";
import { Button } from "@storyteller/ui-button"
import { Checkbox } from "@storyteller/ui-checkbox";
interface Props {
text: string;
@@ -74,10 +75,7 @@ export const ConfirmationModal = ({
<div className="mt-2">{text}</div>
{canHide && (
<div className="mt-2">
<label>
<input id="hide-dialog" type="checkbox" />
&nbsp;&nbsp;Do not show this again
</label>
<Checkbox id="hide-dialog" label="Do not show this again" />
</div>
)}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Checkbox } from "@storyteller/ui-checkbox";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
@@ -89,15 +90,12 @@ export function UploadModal3D(props: Props) {
case UploaderStates.ready:
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<input
id="upload-as-character"
type="checkbox"
checked={isCharacter}
onChange={(e) => setIsCharacter(e.target.checked)}
/>
<label htmlFor="upload-as-character">Upload as Character</label>
</div>
<Checkbox
id="upload-as-character"
checked={isCharacter}
onChange={(e) => setIsCharacter(e.target.checked)}
label="Upload as Character"
/>
<UploadFiles3D
title={title}
engineCategory={selectedCategory}
@@ -73,6 +73,10 @@ import { TabId, useTabStore } from "~/pages/Stores/TabState";
import { authentication } from "~/signals";
import { setLogoutStates } from "~/signals/authentication/utilities";
import type { BaseSelectorImage } from "@storyteller/ui-pagedraw";
import {
galleryModalDeleteMedia,
galleryModalSubscribeToMediaEvents,
} from "~/Helpers/galleryModalTauriBindings";
import { AppsQuickMenu } from "./AppsQuickMenu";
import { SceneTitleInput } from "./SceneTitleInput";
import { TaskQueue } from "./TaskQueue";
@@ -782,6 +786,8 @@ export const TopBar = ({ pageName }: Props) => {
onMake3DObjectClicked={handleMake3DObjectFromGallery}
onMake3DWorldClicked={handleMake3DWorldFromGallery}
onRecreateClicked={handleRecreateFromGallery}
onDeleteMedia={galleryModalDeleteMedia}
subscribeToMediaEvents={galleryModalSubscribeToMediaEvents}
/>
<ProviderSetupModal />
@@ -1,3 +1,4 @@
import { Checkbox } from "@storyteller/ui-checkbox";
import { Modal } from "@storyteller/ui-modal";
import { UploadModal3D } from "~/components/reusable/UploadModal3D";
import {
@@ -590,34 +591,12 @@ export const AssetModal = () => {
))}
</div>
<div className="mt-auto flex items-center gap-2 pt-3">
<div
className="flex cursor-pointer items-center"
onClick={(e) => {
// Prevent double-firing when clicking the checkbox itself
if (e.target instanceof HTMLInputElement) return;
const checkbox = document.getElementById(
"reopen-after-add",
) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
reopenAfterDragSignal.value = checkbox.checked;
}
}}
>
<input
type="checkbox"
id="reopen-after-add"
checked={reopenAfterDragSignal.value}
onChange={handleReopenChange}
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
/>
<label
htmlFor="reopen-after-add"
className="ml-2 cursor-pointer select-none text-sm text-white/70"
>
Reopen after adding
</label>
</div>
<Checkbox
id="reopen-after-add"
checked={reopenAfterDragSignal.value}
onChange={handleReopenChange}
label="Reopen after adding"
/>
</div>
</div>
<div className="col-span-9 p-3 pb-0 ps-0 pt-2">
+77 -25
View File
@@ -51,10 +51,12 @@ body {
/* ===================== THEME VARIABLES ===================== */
:root {
--st-bg: #242424; /* gray theme default */
--st-bg: #242424;
/* gray theme default */
--st-panel: #1f1f1f;
--st-panel-border: #3f3f3f;
--st-modal: #1f1f1f; /* default modal surface (can be gradient per theme) */
--st-modal: #1f1f1f;
/* default modal surface (can be gradient per theme) */
--st-controls: #3e3e41;
--st-controls-rgb: 62 62 65;
--st-controls-border: #3f3f3f;
@@ -76,9 +78,11 @@ body {
--st-panel: #f8f8f8;
--st-panel-border: #e5e7eb;
--st-modal: #f8f8f8;
--st-controls: #eeeeef; /* requested light button surface */
--st-controls: #eeeeef;
/* requested light button surface */
--st-controls-rgb: 238 238 239;
--st-controls-border: #dcdcdf; /* requested light button border */
--st-controls-border: #dcdcdf;
/* requested light button border */
--st-fg: #111827;
--st-fg-rgb: 17 24 39;
--st-divider: #d1d5db;
@@ -94,14 +98,14 @@ body {
.theme-gray {
--st-bg: #242424;
--st-panel: #1f1f1f;
--st-panel-border: #303030;
--st-panel-border: #272727;
--st-modal: #1f1f1f;
--st-controls: #3e3e41;
--st-controls-rgb: 62 62 65;
--st-controls-border: #464646;
--st-fg: #ffffff;
--st-fg-rgb: 255 255 255;
--st-divider: #424242;
--st-divider: #272727;
--st-color-scheme: dark;
--st-gallery-vignette-alpha: 0.3;
--st-photo-tint-rgb: 0 0 0;
@@ -147,7 +151,8 @@ body {
--st-fg-rgb: 248 250 252;
--st-divider: #2a3152;
--st-color-scheme: dark;
--st-photo-tint-rgb: 22 32 56; /* slightly darker bluish tint */
--st-photo-tint-rgb: 22 32 56;
/* slightly darker bluish tint */
--st-photo-tint-alpha: 0.34;
--st-photo-grayscale: 0;
--st-badge-alpha: 0.4;
@@ -170,7 +175,8 @@ body {
--st-fg-rgb: 248 250 252;
--st-divider: #4a2e3a;
--st-color-scheme: dark;
--st-photo-tint-rgb: 96 32 48; /* warmer tint for images */
--st-photo-tint-rgb: 96 32 48;
/* warmer tint for images */
--st-photo-tint-alpha: 0.3;
--st-photo-grayscale: 0;
--st-badge-alpha: 0.4;
@@ -180,37 +186,44 @@ body {
.bg-ui-background {
background: var(--st-bg);
}
.bg-ui-panel {
background: var(--st-panel);
}
.bg-ui-panel-gradient {
background:
radial-gradient(
60.68% 60.68% at 50% 0%,
radial-gradient(60.68% 60.68% at 50% 0%,
rgba(255, 255, 255, 0.08) 0%,
rgba(0, 0, 0, 0) 70%
),
rgba(0, 0, 0, 0) 70%),
var(--st-panel);
background-color: var(--st-panel);
}
.bg-ui-modal {
background: var(--st-modal);
}
.border-ui-panel-border {
border-color: var(--st-panel-border);
}
.border-ui-controls-border {
border-color: var(--st-controls-border);
}
.bg-ui-controls {
background: var(--st-controls);
}
.bg-ui-badge {
background-color: rgba(0, 0, 0, var(--st-badge-alpha));
}
.text-base-fg {
color: rgb(var(--st-fg-rgb) / 1);
}
.border-ui-divider {
border-color: var(--st-divider);
}
@@ -239,6 +252,7 @@ img {
.icon-auto-contrast {
filter: invert(1);
}
.theme-light .icon-auto-contrast {
filter: none;
}
@@ -249,35 +263,45 @@ img {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.promptbox-scrollbar::-webkit-scrollbar {
width: 4px;
}
.promptbox-scrollbar::-webkit-scrollbar:vertical {
width: 4px;
}
.promptbox-scrollbar::-webkit-scrollbar:horizontal {
height: 4px;
}
.promptbox-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.promptbox-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 9999px;
}
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.35);
}
.promptbox-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
.promptbox-scrollbar::-webkit-resizer {
background: transparent;
border: none;
}
.promptbox-resize-wrap {
position: relative;
}
.promptbox-resize-wrap::after {
content: "";
position: absolute;
@@ -288,17 +312,21 @@ img {
pointer-events: none;
background:
linear-gradient(135deg,
transparent 40%, rgba(255, 255, 255, 0.5) 40%, rgba(255, 255, 255, 0.5) 45%, transparent 45%,
transparent 55%, rgba(255, 255, 255, 0.5) 55%, rgba(255, 255, 255, 0.5) 60%, transparent 60%,
transparent 70%, rgba(255, 255, 255, 0.5) 70%, rgba(255, 255, 255, 0.5) 75%, transparent 75%);
transparent 40%, rgba(255, 255, 255, 0.5) 40%, rgba(255, 255, 255, 0.5) 45%, transparent 45%,
transparent 55%, rgba(255, 255, 255, 0.5) 55%, rgba(255, 255, 255, 0.5) 60%, transparent 60%,
transparent 70%, rgba(255, 255, 255, 0.5) 70%, rgba(255, 255, 255, 0.5) 75%, transparent 75%);
}
.scrollbar-hidden {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.scrollbar-hidden::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
display: none;
/* Chrome, Safari, Opera */
}
.topbar-spacer {
@@ -307,9 +335,12 @@ img {
}
.prevent-select {
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
-webkit-user-select: none;
/* Safari */
-ms-user-select: none;
/* IE 10 and IE 11 */
user-select: none;
/* Standard syntax */
pointer-events: none;
}
@@ -357,36 +388,42 @@ img {
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
16.667% {
box-shadow:
9984px -10px 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
33.333% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
50% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px -10px 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
66.667% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
83.333% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px -10px 0 0 #ffffff30;
}
100% {
box-shadow:
9984px 0 0 0 #ffffff30,
@@ -423,72 +460,84 @@ img {
9991px 0 0 0 #ffffff30,
10007px 0 0 0 #ffffff30;
}
8.333% {
box-shadow:
10007px -16px 0 0 #ffffff30,
9991px 0 0 0 #ffffff30,
10007px 0 0 0 #ffffff30;
}
16.667% {
box-shadow:
10007px -16px 0 0 #ffffff30,
9991px -16px 0 0 #ffffff30,
10007px 0 0 0 #ffffff30;
}
25% {
box-shadow:
10007px -16px 0 0 #ffffff30,
9991px -16px 0 0 #ffffff30,
9991px 0 0 0 #ffffff30;
}
33.333% {
box-shadow:
10007px 0 0 0 #ffffff30,
9991px -16px 0 0 #ffffff30,
9991px 0 0 0 #ffffff30;
}
41.667% {
box-shadow:
10007px 0 0 0 #ffffff30,
10007px -16px 0 0 #ffffff30,
9991px 0 0 0 #ffffff30;
}
50% {
box-shadow:
10007px 0 0 0 #ffffff30,
10007px -16px 0 0 #ffffff30,
9991px -16px 0 0 #ffffff30;
}
58.333% {
box-shadow:
9991px 0 0 0 #ffffff30,
10007px -16px 0 0 #ffffff30,
9991px -16px 0 0 #ffffff30;
}
66.666% {
box-shadow:
9991px 0 0 0 #ffffff30,
10007px 0 0 0 #ffffff30,
9991px -16px 0 0 #ffffff30;
}
75% {
box-shadow:
9991px 0 0 0 #ffffff30,
10007px 0 0 0 #ffffff30,
10007px -16px 0 0 #ffffff30;
}
83.333% {
box-shadow:
9991px -16px 0 0 #ffffff30,
10007px 0 0 0 #ffffff30,
10007px -16px 0 0 #ffffff30;
}
91.667% {
box-shadow:
9991px -16px 0 0 #ffffff30,
9991px 0 0 0 #ffffff30,
10007px -16px 0 0 #ffffff30;
}
100% {
box-shadow:
9991px -16px 0 0 #ffffff30,
@@ -505,6 +554,7 @@ img {
.st-loading-tile {
position: relative;
}
.st-border-svg {
position: absolute;
inset: -2px;
@@ -525,17 +575,18 @@ img {
/* Single runner: a solid line chasing its tail */
/* Continuous runner with wrapping to avoid flicker at corners */
.st-border-solid {
stroke-dasharray: 90 110; /* shorter visible segment + larger gap = clearer motion */
stroke-dasharray: 90 110;
/* shorter visible segment + larger gap = clearer motion */
stroke-dashoffset: 0;
animation: st-solid-wrap 1.6s linear infinite;
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 18px rgba(59, 130, 246, 0.5));
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.8)) drop-shadow(0 0 18px rgba(59, 130, 246, 0.5));
}
@keyframes st-solid-wrap {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -200;
}
@@ -545,7 +596,8 @@ img {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-checkbox
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-checkbox` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-checkbox",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,2 @@
export * from './lib/checkbox';
@@ -0,0 +1,136 @@
import {
forwardRef,
useCallback,
useId,
useState,
type ChangeEvent,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
const CheckIcon = ({ className }: { className?: string }) => (
<svg
aria-hidden
viewBox="0 0 10 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={twMerge("block", className)}
>
<path
d="M1 4L3.6 6.6L9 1"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
type NativeInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
"type" | "size"
>;
export interface CheckboxProps extends NativeInputProps {
label?: ReactNode;
size?: "sm" | "md";
/** Overrides the visual box styling (the rounded square). */
checkboxClassName?: string;
/** Overrides the label text styling. */
labelClassName?: string;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(
{
label,
size = "md",
className,
checkboxClassName,
labelClassName,
id,
disabled,
checked,
defaultChecked,
onChange,
...inputProps
},
ref,
) => {
const generatedId = useId();
const inputId = id ?? generatedId;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState(!!defaultChecked);
const isChecked = isControlled ? checked : internal;
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (!isControlled) setInternal(e.target.checked);
onChange?.(e);
},
[isControlled, onChange],
);
const boxDims = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
const iconDims = size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5";
return (
<label
htmlFor={inputId}
className={twMerge(
"inline-flex items-center gap-2 select-none leading-none",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
className,
)}
>
<input
ref={ref}
id={inputId}
type="checkbox"
disabled={disabled}
checked={isChecked}
onChange={handleChange}
{...inputProps}
className="peer sr-only"
/>
<span
aria-hidden
className={twMerge(
"relative inline-block shrink-0 rounded-[4px] border transition-colors",
boxDims,
isChecked
? "border-primary bg-primary text-white"
: "border-ui-controls-border bg-ui-controls hover:border-primary/60",
"peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40",
checkboxClassName,
)}
>
{isChecked && (
<CheckIcon
className={twMerge(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
iconDims,
)}
/>
)}
</span>
{label != null && (
<span
className={twMerge(
"text-sm text-base-fg/70",
disabled && "text-base-fg/40",
labelClassName,
)}
>
{label}
</span>
)}
</label>
);
},
);
Checkbox.displayName = "Checkbox";
export default Checkbox;
@@ -0,0 +1,13 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,45 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
]
}
@@ -0,0 +1,34 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,49 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/checkbox',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-checkbox',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -17,7 +17,6 @@ import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { MediaFileDelete } from "@storyteller/tauri-api";
import { toast } from "@storyteller/ui-toaster";
type ModalMode = "select" | "view";
@@ -32,6 +31,8 @@ interface GalleryDraggableItemProps {
disableTooltipAndBadge?: boolean;
imageFit?: "cover" | "contain";
onDeleted?: (id: string) => void;
/** Performs the actual delete. When absent, the delete menu item is hidden. */
onDelete?: (id: string) => Promise<unknown>;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
maxSelections?: number;
bulkSelected?: boolean;
@@ -49,6 +50,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
disableTooltipAndBadge = false,
imageFit = "cover",
onDeleted,
onDelete,
onEditClicked,
maxSelections,
bulkSelected = false,
@@ -87,6 +89,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
};
const handleDelete = () => {
if (!onDelete) return;
showActionReminder({
reminderType: "default",
title: "Delete this media?",
@@ -101,18 +104,22 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
try {
const result = await MediaFileDelete(item.id);
console.log("MediaFileDelete result:", result);
if (result && "status" in result && result.status !== "success") {
const result = await onDelete(item.id);
if (
result &&
typeof result === "object" &&
"status" in result &&
(result as any).status !== "success"
) {
const msg =
(result as any).error_message || "Failed to delete media.";
console.error("MediaFileDelete failed:", result);
console.error("Delete failed:", result);
toast.error(msg);
return;
}
onDeleted?.(item.id);
} catch (err) {
console.error("MediaFileDelete threw:", err);
console.error("Delete threw:", err);
toast.error(
`Failed to delete media: ${err instanceof Error ? err.message : String(err)}`,
);
@@ -283,18 +290,20 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
<span>Edit image</span>
</button>
)}
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
close();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
<span className="text-red">Delete</span>
</button>
{onDelete && (
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
close();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
<span className="text-red">Delete</span>
</button>
)}
</div>
)}
</PopoverMenu>
@@ -57,8 +57,7 @@ import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { MediaFileDelete } from "@storyteller/tauri-api";
import { listen } from "@tauri-apps/api/event";
import { Checkbox } from "@storyteller/ui-checkbox";
// ─── Module-level gallery cache ───────────────────────────────────────────────
// Persists gallery items across modal open/close so users see content instantly.
@@ -260,6 +259,15 @@ interface GalleryModalProps {
promptData: Prompts;
mediaClass: string | undefined;
}) => void;
/** Hide the filter popover entirely. When unset, the filter button shows (optionally locked via forceFilter). */
hideFilter?: boolean;
/** Delete a media file by id. Required in view mode to show any delete UI. */
onDeleteMedia?: (id: string) => Promise<unknown>;
/** Wire external event sources (generation-complete, media-deleted). Returns an unsubscribe fn. */
subscribeToMediaEvents?: (handlers: {
onGenerationComplete: () => void;
onMediaDeleted: (mediaId: string) => void;
}) => () => void;
}
// --- Constants (never re-created) ---
@@ -377,6 +385,9 @@ export const GalleryModal = React.memo(
onMake3DObjectClicked,
onMake3DWorldClicked,
onRecreateClicked,
hideFilter,
onDeleteMedia,
subscribeToMediaEvents,
}: GalleryModalProps) => {
const [loading, setLoading] = useState(false);
// Separate state for pagination spinner (bottom of list) — not shared with background refresh
@@ -394,12 +405,22 @@ export const GalleryModal = React.memo(
const [activeFilter, setActiveFilter] = useState(forceFilter || "all");
const minColumns = 3;
const maxColumns = 12;
// Default gridColumns to 5
// Default gridColumns to 5 on desktop; narrow screens are locked to 2 columns
const defaultGridColumns = 5;
const [isNarrow, setIsNarrow] = useState(
() => typeof window !== "undefined" && window.innerWidth < 640,
);
useEffect(() => {
const handler = () => setIsNarrow(window.innerWidth < 640);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
const [sliderValue, setSliderValue] = useState(
maxColumns - (defaultGridColumns - minColumns),
);
const gridColumns = maxColumns - (sliderValue - minColumns);
const gridColumns = isNarrow
? 2
: maxColumns - (sliderValue - minColumns);
const [imageFit, setImageFit] = useState<"cover" | "contain">("contain");
const [showUploads, setShowUploads] = useState<boolean>(() => {
try {
@@ -489,7 +510,7 @@ export const GalleryModal = React.memo(
// call it without taking a dependency on it. refreshGallery itself is
// defined lower in the file (it needs loadItems); we wire the ref via a
// layout-time assignment below.
const refreshGalleryRef = useRef<() => void>(() => {});
const refreshGalleryRef = useRef<() => void>(() => { });
// Fetch the current session's username every time the modal opens.
// The modal is permanently mounted in TopBar, so this component survives
@@ -549,8 +570,8 @@ export const GalleryModal = React.memo(
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: getThumbnailUrl(item.media_links.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.MEDIUM,
}),
width: THUMBNAIL_SIZES.MEDIUM,
}),
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
@@ -741,43 +762,38 @@ export const GalleryModal = React.memo(
: true;
}, [mode, isOpen, galleryModalVisibleViewMode.value]);
// External media-event subscription. Caller (typically the desktop app's
// Tauri bindings) wires `subscribeToMediaEvents` to fire `onGenerationComplete`
// when a generation finishes and `onMediaDeleted(id)` when any client deletes
// an item, so the gallery refreshes and drops items live. When the prop is
// absent (e.g. on the web), these behaviors just don't run.
useEffect(() => {
const unlistenPromise = listen("generation-complete-event", () => {
if (modalIsOpenRef.current && username) {
refreshGallery();
}
});
return () => {
unlistenPromise.then((fn) => fn());
};
}, [refreshGallery, username]);
// Drop any item deleted anywhere in the app (lightbox, single, bulk)
// so it disappears from the gallery immediately.
useEffect(() => {
const unlistenPromise = listen<any>("media_file_deleted_event", (event) => {
const token: string | undefined =
event?.payload?.data?.media_file_token ??
event?.payload?.media_file_token;
if (!token) return;
setAllItems((prev) => prev.filter((it) => it.id !== token));
setBulkSelectedIds((prev) => {
if (!prev.has(token)) return prev;
const next = new Set(prev);
next.delete(token);
return next;
});
for (const [key, entry] of galleryCacheMap.entries()) {
galleryCacheMap.set(key, {
...entry,
items: entry.items.filter((it) => it.id !== token),
if (!subscribeToMediaEvents) return;
const unsubscribe = subscribeToMediaEvents({
onGenerationComplete: () => {
if (modalIsOpenRef.current && username) {
refreshGallery();
}
},
onMediaDeleted: (token) => {
if (!token) return;
setAllItems((prev) => prev.filter((it) => it.id !== token));
setBulkSelectedIds((prev) => {
if (!prev.has(token)) return prev;
const next = new Set(prev);
next.delete(token);
return next;
});
}
for (const [key, entry] of galleryCacheMap.entries()) {
galleryCacheMap.set(key, {
...entry,
items: entry.items.filter((it) => it.id !== token),
});
}
},
});
return () => {
unlistenPromise.then((fn) => fn());
};
}, []);
return unsubscribe;
}, [subscribeToMediaEvents, refreshGallery, username]);
const toggleBulkSelect = useCallback((id: string) => {
setBulkSelectedIds((prev) => {
@@ -860,6 +876,7 @@ export const GalleryModal = React.memo(
);
const handleBulkDelete = useCallback(() => {
if (!onDeleteMedia) return;
const count = bulkSelectedIds.size;
showActionReminder({
reminderType: "default",
@@ -876,7 +893,7 @@ export const GalleryModal = React.memo(
onPrimaryAction: async () => {
try {
const ids = Array.from(bulkSelectedIds);
await Promise.allSettled(ids.map((id) => MediaFileDelete(id)));
await Promise.allSettled(ids.map((id) => onDeleteMedia(id)));
const idSet = new Set(ids);
setAllItems((prev) => prev.filter((it) => !idSet.has(it.id)));
for (const [key, entry] of galleryCacheMap.entries()) {
@@ -891,7 +908,7 @@ export const GalleryModal = React.memo(
}
},
});
}, [bulkSelectedIds, clearBulkSelection]);
}, [bulkSelectedIds, clearBulkSelection, onDeleteMedia]);
useSignals();
@@ -1033,7 +1050,7 @@ export const GalleryModal = React.memo(
isOpen={
mode === "view"
? galleryModalVisibleViewMode.value &&
galleryModalVisibleDuringDrag.value
galleryModalVisibleDuringDrag.value
: typeof isOpen === "boolean"
? isOpen
: true
@@ -1047,9 +1064,9 @@ export const GalleryModal = React.memo(
}
}}
className={twMerge(
"h-[620px] max-w-4xl rounded-xl",
"h-[620px] max-h-[90vh] w-full max-w-4xl rounded-xl",
mode === "view" &&
"h-[640px] min-h-[640px] min-w-[56rem] w-[56rem] max-w-none",
"h-[640px] min-h-[640px] min-w-[56rem] w-[56rem] max-w-none",
)}
childPadding={false}
showClose={false}
@@ -1064,8 +1081,8 @@ export const GalleryModal = React.memo(
)}
<div className="flex h-full flex-col">
<div className="border-b border-ui-panel-border p-4 py-3 bg-ui-panel rounded-t-xl">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="flex flex-wrap justify-between items-center gap-y-2">
<div className="flex items-center gap-3 flex-wrap gap-y-1">
<h2 className="text-xl font-semibold">
{mode === "select"
? activeFilter === "video"
@@ -1088,44 +1105,30 @@ export const GalleryModal = React.memo(
: "My Library"}
</h2>
{mode === "view" && (
<div className="flex items-center relative z-[51]">
<input
type="checkbox"
<div className="relative z-[51] flex items-center">
<Checkbox
id="gallery-reopen-after-drag"
checked={galleryReopenAfterDragSignal.value}
onChange={(e) =>
(galleryReopenAfterDragSignal.value =
e.target.checked)
(galleryReopenAfterDragSignal.value =
e.target.checked)
}
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
label="Reopen after adding"
/>
<label
htmlFor="gallery-reopen-after-drag"
className="ml-2 cursor-pointer select-none text-sm text-base-fg/70"
>
Reopen after adding
</label>
</div>
)}
{activeFilter !== "uploaded" && (
<div className="flex items-center relative z-[51]">
<input
type="checkbox"
<div className="relative z-[51] flex items-center">
<Checkbox
id="gallery-show-uploads"
checked={showUploads}
onChange={(e) => setShowUploads(e.target.checked)}
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
label="Show uploads"
/>
<label
htmlFor="gallery-show-uploads"
className="ml-2 cursor-pointer select-none text-sm text-base-fg/70"
>
Show uploads
</label>
</div>
)}
</div>
<div className="flex justify-end gap-2 items-center">
<div className="flex justify-end gap-2 items-center flex-wrap ml-auto">
{/* Refresh button */}
<Tooltip
position="top"
@@ -1135,7 +1138,7 @@ export const GalleryModal = React.memo(
<Button
variant="action"
onClick={refreshGallery}
className="relative z-[51] h-9 w-9 bg-ui-controls/60 hover:bg-ui-controls/90"
className="relative z-[51] h-9 w-9"
disabled={loading}
aria-label="Refresh list"
>
@@ -1158,7 +1161,7 @@ export const GalleryModal = React.memo(
fit === "cover" ? "contain" : "cover",
)
}
className="relative z-[51] h-9 w-9 bg-ui-controls/60 hover:bg-ui-controls/90"
className="relative z-[51] h-9 w-9"
>
<FontAwesomeIcon
icon={imageFit === "cover" ? faExpand : faCompress}
@@ -1167,8 +1170,8 @@ export const GalleryModal = React.memo(
</Button>
</Tooltip>
{/* Slider */}
<div className="w-32 mx-3 relative z-[51] flex items-center gap-2">
{/* Slider (hidden on narrow screens — grid is locked to 2 cols there) */}
<div className="hidden sm:flex w-32 mx-3 relative z-[51] items-center gap-2">
<SliderV2
min={minColumns}
max={maxColumns}
@@ -1179,55 +1182,55 @@ export const GalleryModal = React.memo(
showTooltip={false}
className="w-full"
showProgressBar={false}
tooltipContent={`${
maxColumns - (sliderValue - minColumns)
} columns`}
tooltipContent={`${maxColumns - (sliderValue - minColumns)
} columns`}
/>
</div>
{/* Filter popover */}
<Tooltip
position="top"
content={
forceFilter ? "Filter locked" : "Filter"
}
closeOnClick={true}
>
<PopoverMenu
panelTitle="Filter"
position="bottom"
align="end"
buttonClassName={`relative z-[51] mr-3 ${
forceFilter
{!hideFilter && (
<Tooltip
position="top"
content={
forceFilter ? "Filter locked" : "Filter"
}
closeOnClick={true}
>
<PopoverMenu
panelTitle="Filter"
position="bottom"
align="end"
buttonClassName={`relative z-[51] mr-3 ${forceFilter
? "opacity-70 pointer-events-none"
: ""
}`}
panelClassName="min-w-36"
items={FILTERS.map((f) => ({
label: f.label,
selected: activeFilter === f.id,
icon: f.icon,
// Use a custom property that will be passed through but not cause type errors
customProps: {
disabled: forceFilter !== undefined,
},
}))}
onSelect={(item) => {
// Only allow filter changes if no forceFilter was provided
if (!forceFilter) {
const filter = FILTERS.find(
(f) => f.label === item.label,
);
if (filter) setActiveFilter(filter.id);
}`}
panelClassName="min-w-36"
items={FILTERS.map((f) => ({
label: f.label,
selected: activeFilter === f.id,
icon: f.icon,
// Use a custom property that will be passed through but not cause type errors
customProps: {
disabled: forceFilter !== undefined,
},
}))}
onSelect={(item) => {
// Only allow filter changes if no forceFilter was provided
if (!forceFilter) {
const filter = FILTERS.find(
(f) => f.label === item.label,
);
if (filter) setActiveFilter(filter.id);
}
}}
triggerIcon={<FontAwesomeIcon icon={faFilter} />}
triggerLabel={
FILTERS.find((f) => f.id === activeFilter)?.label
}
}}
triggerIcon={<FontAwesomeIcon icon={faFilter} />}
triggerLabel={
FILTERS.find((f) => f.id === activeFilter)?.label
}
mode="toggle"
showIconsInList={true}
/>
</Tooltip>
mode="toggle"
showIconsInList={true}
/>
</Tooltip>
)}
{mode === "view" && <Modal.ExpandButton />}
<CloseButton
onClick={() => {
@@ -1332,6 +1335,7 @@ export const GalleryModal = React.memo(
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
onDelete={onDeleteMedia}
onEditClicked={onEditClicked}
maxSelections={maxSelections}
bulkSelected={bulkSelectedIds.has(item.id)}
@@ -1393,14 +1397,16 @@ export const GalleryModal = React.memo(
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="destructive"
onClick={handleBulkDelete}
className="px-3"
icon={faTrashCan}
>
Delete
</Button>
{onDeleteMedia && (
<Button
variant="destructive"
onClick={handleBulkDelete}
className="px-3"
icon={faTrashCan}
>
Delete
</Button>
)}
<button
onClick={clearBulkSelection}
className="flex h-8 w-8 items-center justify-center rounded-md bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg transition-colors"
@@ -168,8 +168,8 @@ export const Modal = ({
titleIcon,
onTitleIconClick,
onClose,
enableHotkeyInput = () => {},
disableHotkeyInput = () => {},
enableHotkeyInput = () => { },
disableHotkeyInput = () => { },
className,
backdropClassName,
width,
@@ -448,47 +448,47 @@ export const Modal = ({
let foundHandle = false;
enhancedChildren = Array.isArray(children)
? children.map((child) => {
if (
!foundHandle &&
isValidElement(child) &&
(child.type === DragHandle ||
(child.type as any).displayName === "ModalDragHandle")
) {
foundHandle = true;
const typedChild = child as React.ReactElement<{
children: ReactNode;
}>;
return cloneElement(typedChild, {
children: (
<div
style={{ cursor: "move", userSelect: "none" }}
onMouseDown={handleDragStart}
>
{typedChild.props.children}
</div>
),
});
}
return child;
})
if (
!foundHandle &&
isValidElement(child) &&
(child.type === DragHandle ||
(child.type as any).displayName === "ModalDragHandle")
) {
foundHandle = true;
const typedChild = child as React.ReactElement<{
children: ReactNode;
}>;
return cloneElement(typedChild, {
children: (
<div
style={{ cursor: "move", userSelect: "none" }}
onMouseDown={handleDragStart}
>
{typedChild.props.children}
</div>
),
});
}
return child;
})
: isValidElement(children) &&
(children.type === DragHandle ||
(children.type as any).displayName === "ModalDragHandle")
(children.type === DragHandle ||
(children.type as any).displayName === "ModalDragHandle")
? (() => {
const typedChildren = children as React.ReactElement<{
children: ReactNode;
}>;
return cloneElement(typedChildren, {
children: (
<div
style={{ cursor: "move", userSelect: "none" }}
onMouseDown={handleDragStart}
>
{typedChildren.props.children}
</div>
),
});
})()
const typedChildren = children as React.ReactElement<{
children: ReactNode;
}>;
return cloneElement(typedChildren, {
children: (
<div
style={{ cursor: "move", userSelect: "none" }}
onMouseDown={handleDragStart}
>
{typedChildren.props.children}
</div>
),
});
})()
: children;
}
@@ -552,7 +552,7 @@ export const Modal = ({
minWidth = Math.max(minWidth, parsedMinW);
if (!Number.isNaN(parsedMinH) && parsedMinH > 0)
minHeight = Math.max(minHeight, parsedMinH);
} catch {}
} catch { }
if (dir.includes("right")) {
newWidth = resizeStart.current.width + dx;
@@ -980,7 +980,7 @@ export const Modal = ({
className,
"!transition-none", // Always disable CSS transitions for spring animations
expanded &&
"w-screen h-screen max-w-screen max-h-screen rounded-none",
"w-screen h-screen max-w-screen max-h-screen rounded-none",
)}
ref={modalRef}
style={{
@@ -965,7 +965,7 @@ export const ImagePromptRow = ({
interactive={true}
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
closeOnClick={true}
content={
<div className="flex flex-col gap-1.5">
@@ -1077,7 +1077,7 @@ export const ImagePromptRow = ({
interactive={true}
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
closeOnClick={true}
content={
<div className="flex flex-col gap-1.5">
@@ -1198,7 +1198,7 @@ export const ImagePromptRow = ({
interactive={true}
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
className="bg-ui-controls text-base-fg border border-ui-controls-border p-2 -mb-0.5"
closeOnClick={true}
content={
<div className="flex flex-col gap-1.5">
@@ -315,7 +315,7 @@ export const Tooltip = ({
}}
className={twMerge(
isFixed ? "fixed" : "absolute",
"w-max rounded-lg bg-ui-controls shadow-xl border border-ui-panel-border",
"w-max rounded-lg bg-ui-controls shadow-xl border border-ui-controls-border",
interactive
? "pointer-events-auto p-3"
: "px-2.5 py-1.5 text-[13px] font-medium pointer-events-none",
+145 -2058
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -185,6 +185,9 @@
},
{
"path": "./libs/components/pagedraw"
},
{
"path": "./libs/components/checkbox"
}
]
}