mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 17:49:30 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa5fad440f |
@@ -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" />
|
||||
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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+145
-2058
File diff suppressed because it is too large
Load Diff
@@ -185,6 +185,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/pagedraw"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/checkbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user