87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | // ----- Quota / Persistence helpers -----
9 |
10 | type QuotaInfo = {
11 | usage: number; // bytes
12 | quota: number; // bytes
13 | persisted: boolean;
14 | };
15 |
16 | export function isFirefox(): boolean {
17 | if (typeof navigator === 'undefined') return false;
18 | return /firefox/i.test(navigator.userAgent);
19 | }
20 |
21 | export function toGiB(bytes: number) {
22 | return bytes / (1024 ** 3);
23 | }
24 |
25 | export async function getQuotaInfo(): Promise {
26 | const est = await navigator.storage?.estimate?.();
27 | const persisted = await navigator.storage?.persisted?.();
28 | return {
29 | usage: est?.usage ?? 0,
30 | quota: est?.quota ?? 0,
31 | persisted: !!persisted,
32 | };
33 | }
34 |
35 | /**
36 | * Requests persistent storage if not already granted.
37 | * Returns true if the origin is persisted after this call.
38 | */
39 | export async function ensurePersistentStorage(
40 | confirmPrompt?: () => Promise | boolean
41 | ): Promise {
42 | if (!('storage' in navigator) || !navigator.storage?.persist) return false;
43 |
44 | const already = await navigator.storage.persisted?.();
45 | if (already) return true;
46 |
47 | // Ask the user. You can replace this with a custom modal.
48 | let okToAsk = true;
49 | if (confirmPrompt) {
50 | okToAsk = await confirmPrompt();
51 | } else if (typeof window !== 'undefined' && 'confirm' in window) {
52 | okToAsk = window.confirm(
53 | 'Allow this app to use persistent storage? This helps store more images and prevents unexpected deletion.'
54 | );
55 | }
56 |
57 | if (!okToAsk) return false;
58 |
59 | const granted = await navigator.storage.persist();
60 | return !!granted;
61 | }
62 |
63 | /**
64 | * Preflight: checks if (current usage + incomingBytes) fits in current quota.
65 | * If not, tries to get persistent storage once and rechecks.
66 | * Returns the final QuotaInfo and whether it looks safe to proceed.
67 | */
68 | export async function preflightQuotaOrPersist(incomingBytes: number, confirmPrompt?: () => Promise | boolean) {
69 | let info = await getQuotaInfo();
70 | const headroom = 10 * 1024 * 1024; // 10MB headroom
71 |
72 | // If storage is already persistent, we just check if there's space.
73 | // No need to prompt.
74 | if (info.persisted) {
75 | const canProceed = info.usage + incomingBytes <= info.quota - headroom;
76 | return { info, canProceed, requestedPersist: false };
77 | }
78 |
79 | // If not persistent, check if the new files fit in the current temporary quota.
80 | const fitsInTemporaryQuota = info.usage + incomingBytes <= info.quota - headroom;
81 | if (fitsInTemporaryQuota) {
82 | return { info, canProceed: true, requestedPersist: false };
83 | }
84 |
85 | // If we're here, it means storage is NOT persistent AND there's not enough space.
86 | // This is the only time we should ask the user for permission.
87 | const requestedAndGranted = await ensurePersistentStorage(confirmPrompt);
88 |
89 | // Re-check quota info after potentially getting persistence.
90 | info = await getQuotaInfo();
91 | const fitsAfterRequest = info.usage + incomingBytes <= info.quota - headroom;
92 |
93 | return { info, canProceed: fitsAfterRequest, requestedPersist: requestedAndGranted };
94 | }
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import { Drawer as DrawerPrimitive } from "vaul"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Drawer = ({
10 | shouldScaleBackground = true,
11 | ...props
12 | }: React.ComponentProps) => (
13 |
17 | )
18 | Drawer.displayName = "Drawer"
19 |
20 | const DrawerTrigger = DrawerPrimitive.Trigger
21 |
22 | const DrawerPortal = DrawerPrimitive.Portal
23 |
24 | const DrawerClose = DrawerPrimitive.Close
25 |
26 | const DrawerOverlay = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
35 | ))
36 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
37 |
38 | const DrawerContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, children, ...props }, ref) => (
42 |
43 |
44 |
52 |
53 | {children}
54 |
55 |
56 | ))
57 | DrawerContent.displayName = "DrawerContent"
58 |
59 | const DrawerHeader = ({
60 | className,
61 | ...props
62 | }: React.HTMLAttributes) => (
63 |
67 | )
68 | DrawerHeader.displayName = "DrawerHeader"
69 |
70 | const DrawerFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
78 | )
79 | DrawerFooter.displayName = "DrawerFooter"
80 |
81 | const DrawerTitle = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
93 | ))
94 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
95 |
96 | const DrawerDescription = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
107 |
108 | export {
109 | Drawer,
110 | DrawerPortal,
111 | DrawerOverlay,
112 | DrawerTrigger,
113 | DrawerClose,
114 | DrawerContent,
115 | DrawerHeader,
116 | DrawerFooter,
117 | DrawerTitle,
118 | DrawerDescription,
119 | }
120 |
--------------------------------------------------------------------------------
/src/lib/file-tree.test.ts:
--------------------------------------------------------------------------------
1 | import { buildFileTree } from "./file-tree";
2 | import { StoredImage } from "./image-db";
3 |
4 | // Mock StoredImage type for testing purposes
5 | const createMockImage = (
6 | id: number,
7 | path: string,
8 | lastModified: number
9 | ): StoredImage => ({
10 | id,
11 | webkitRelativePath: path,
12 | name: path.split("/").pop() || "",
13 | lastModified,
14 | type: "image/png",
15 | size: 1024,
16 | width: 1024,
17 | height: 1024,
18 | thumbnail: "",
19 | workflow: null,
20 | prompt: null,
21 | negativePrompt: null,
22 | seed: null,
23 | cfg: null,
24 | steps: null,
25 | sampler: null,
26 | scheduler: null,
27 | model: null,
28 | loras: [],
29 | });
30 |
31 | describe("buildFileTree", () => {
32 | it("should return null for empty or invalid input", () => {
33 | expect(buildFileTree([])).toBeNull();
34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
35 | expect(buildFileTree(null as any)).toBeNull();
36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
37 | expect(buildFileTree(undefined as any)).toBeNull();
38 | });
39 |
40 | it("should build a simple tree with one folder and one file", () => {
41 | const files = [createMockImage(1, "root/image1.png", 1000)];
42 | const tree = buildFileTree(files);
43 |
44 | expect(tree).not.toBeNull();
45 | expect(tree?.name).toBe("root");
46 | expect(tree?.children.length).toBe(1);
47 | expect(tree?.children[0].type).toBe("file");
48 | expect(tree?.children[0].name).toBe("image1.png");
49 | });
50 |
51 | it("should correctly structure nested folders", () => {
52 | const files = [
53 | createMockImage(1, "root/sub/image1.png", 1000),
54 | createMockImage(2, "root/image2.png", 2000),
55 | ];
56 | const tree = buildFileTree(files);
57 |
58 | expect(tree?.name).toBe("root");
59 | const rootChildren = tree?.children || [];
60 | expect(rootChildren.length).toBe(2);
61 |
62 | const subFolder = rootChildren.find((n) => n.type === "folder");
63 | const rootFile = rootChildren.find((n) => n.type === "file");
64 |
65 | expect(subFolder?.name).toBe("sub");
66 | expect(rootFile?.name).toBe("image2.png");
67 |
68 | const subChildren = subFolder?.children || [];
69 | expect(subChildren.length).toBe(1);
70 | expect(subChildren[0].name).toBe("image1.png");
71 | });
72 |
73 | it("should sort children with folders first, then by date descending", () => {
74 | const files = [
75 | createMockImage(1, "root/image_old.png", 1000),
76 | createMockImage(2, "root/folder_new/image.png", 3000),
77 | createMockImage(3, "root/image_new.png", 2000),
78 | createMockImage(4, "root/folder_old/image.png", 500),
79 | ];
80 | const tree = buildFileTree(files);
81 | const children = tree?.children || [];
82 |
83 | expect(children.map((c) => c.name)).toEqual([
84 | "folder_new",
85 | "folder_old",
86 | "image_new.png",
87 | "image_old.png",
88 | ]);
89 | });
90 |
91 | it("should update folder lastModified date to the newest child's date", () => {
92 | const files = [
93 | createMockImage(1, "root/sub/image_new.png", 3000),
94 | createMockImage(2, "root/sub/image_old.png", 1000),
95 | createMockImage(3, "root/image_root.png", 2000),
96 | ];
97 | const tree = buildFileTree(files);
98 |
99 | const subFolder = tree?.children.find((c) => c.name === "sub");
100 | expect(subFolder?.lastModified).toBe(3000);
101 | expect(tree?.lastModified).toBe(3000); // Root folder should also be updated
102 | });
103 | });
--------------------------------------------------------------------------------
/src/hooks/use-image-filtering.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { AdvancedSearchState } from "@/components/advanced-search-form";
6 | import { StoredImage } from "@/lib/image-db";
7 |
8 | import { useDebounce } from "./use-debounce";
9 |
10 | type SortBy = "lastModified" | "size";
11 | type SortOrder = "asc" | "desc";
12 |
13 | function checkMatch(value: string | null | undefined, query: string): boolean {
14 | if (!query) return true;
15 | if (value === null || value === undefined) return false;
16 | return String(value).toLowerCase().includes(query.toLowerCase());
17 | }
18 |
19 | export function useImageFiltering(allImageMetadata: StoredImage[], selectedPath: string) {
20 | const [viewSubfolders, setViewSubfolders] = React.useState(false);
21 | const [sortBy, setSortBy] = React.useState("lastModified");
22 | const [sortOrder, setSortOrder] = React.useState("desc");
23 | const [filterQuery, setFilterQuery] = React.useState("");
24 | const [advancedSearchState, setAdvancedSearchState] = React.useState({
25 | prompt: "", negativePrompt: "", seed: "", cfg: "", steps: "", sampler: "", scheduler: "",
26 | });
27 |
28 | const debouncedFilterQuery = useDebounce(filterQuery, 300);
29 | const debouncedAdvancedSearch = useDebounce(advancedSearchState, 300);
30 |
31 | const processedImages = React.useMemo(() => {
32 | if (!selectedPath) return [];
33 | let filtered = allImageMetadata.filter(image => {
34 | const parentDirectory = image.webkitRelativePath.substring(0, image.webkitRelativePath.lastIndexOf("/"));
35 | return viewSubfolders
36 | ? image.webkitRelativePath.startsWith(selectedPath)
37 | : parentDirectory === selectedPath;
38 | });
39 |
40 | if (debouncedFilterQuery) {
41 | const lowerCaseQuery = debouncedFilterQuery.toLowerCase();
42 | filtered = filtered.filter(image =>
43 | image.name.toLowerCase().includes(lowerCaseQuery) ||
44 | (image.workflow && image.workflow.toLowerCase().includes(lowerCaseQuery))
45 | );
46 | }
47 |
48 | const isAdvancedSearchActive = Object.values(debouncedAdvancedSearch).some(v => v !== "");
49 | if (isAdvancedSearchActive) {
50 | filtered = filtered.filter(image =>
51 | checkMatch(image.prompt, debouncedAdvancedSearch.prompt) &&
52 | checkMatch(image.negativePrompt, debouncedAdvancedSearch.negativePrompt) &&
53 | checkMatch(image.seed, debouncedAdvancedSearch.seed) &&
54 | checkMatch(image.cfg, debouncedAdvancedSearch.cfg) &&
55 | checkMatch(image.steps, debouncedAdvancedSearch.steps) &&
56 | checkMatch(image.sampler, debouncedAdvancedSearch.sampler) &&
57 | checkMatch(image.scheduler, debouncedAdvancedSearch.scheduler)
58 | );
59 | }
60 |
61 | filtered.sort((a, b) => {
62 | const compareA = a[sortBy];
63 | const compareB = b[sortBy];
64 | if (compareA === compareB) return 0;
65 | return sortOrder === 'asc' ? (compareA > compareB ? 1 : -1) : (compareA < compareB ? 1 : -1);
66 | });
67 | return filtered;
68 | }, [allImageMetadata, selectedPath, viewSubfolders, debouncedFilterQuery, debouncedAdvancedSearch, sortBy, sortOrder]);
69 |
70 | const handleAdvancedSearchChange = (newState: Partial) => {
71 | setAdvancedSearchState(prev => ({ ...prev, ...newState }));
72 | };
73 |
74 | const handleAdvancedSearchReset = () => {
75 | setAdvancedSearchState({ prompt: "", negativePrompt: "", seed: "", cfg: "", steps: "", sampler: "", scheduler: "" });
76 | };
77 |
78 | return {
79 | processedImages,
80 | viewSubfolders,
81 | setViewSubfolders,
82 | sortBy,
83 | setSortBy,
84 | sortOrder,
85 | setSortOrder,
86 | filterQuery,
87 | setFilterQuery,
88 | advancedSearchState,
89 | handleAdvancedSearchChange,
90 | handleAdvancedSearchReset,
91 | };
92 | }
--------------------------------------------------------------------------------
/src/components/app-header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { ArrowDownWideNarrow, ArrowUpNarrowWide } from "lucide-react";
6 |
7 | import { ThemeToggle } from "@/components/theme-toggle";
8 | import { Button } from "@/components/ui/button";
9 | import { Label } from "@/components/ui/label";
10 | import { Progress } from "@/components/ui/progress";
11 | import {
12 | Select,
13 | SelectContent,
14 | SelectItem,
15 | SelectTrigger,
16 | SelectValue,
17 | } from "@/components/ui/select";
18 | import { Slider } from "@/components/ui/slider";
19 |
20 | type SortBy = "lastModified" | "size";
21 | type SortOrder = "asc" | "desc";
22 |
23 | interface AppHeaderProps {
24 | isLoading: boolean;
25 | progress: number;
26 | gridCols: number;
27 | onGridColsChange: (cols: number) => void;
28 | sortBy: SortBy;
29 | onSortByChange: (sortBy: SortBy) => void;
30 | sortOrder: SortOrder;
31 | onSortOrderChange: (sortOrder: SortOrder) => void;
32 | }
33 |
34 | const MIN_COLS = 1;
35 | const MAX_COLS = 12;
36 |
37 | export function AppHeader({
38 | isLoading,
39 | progress,
40 | gridCols,
41 | onGridColsChange,
42 | sortBy,
43 | onSortByChange,
44 | sortOrder,
45 | onSortOrderChange,
46 | }: AppHeaderProps) {
47 | const handleSliderChange = (value: number[]) => {
48 | const newGridCols = MAX_COLS + MIN_COLS - value[0];
49 | onGridColsChange(newGridCols);
50 | };
51 |
52 | const sliderValue = MAX_COLS + MIN_COLS - gridCols;
53 |
54 | return (
55 |
114 | );
115 | }
--------------------------------------------------------------------------------
/src/components/file-tree.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { FileImage, Folder, FolderOpen } from "lucide-react";
6 |
7 | import {
8 | Collapsible,
9 | CollapsibleContent,
10 | CollapsibleTrigger,
11 | } from "@/components/ui/collapsible";
12 | import { FileTreeNode } from "@/lib/file-tree";
13 | import { cn } from "@/lib/utils";
14 |
15 | interface FileTreeProps {
16 | tree: FileTreeNode;
17 | selectedPath: string;
18 | onSelectPath: (path: string) => void;
19 | selectedImageId: number | null;
20 | onSelectFile: (id: number) => void;
21 | }
22 |
23 | export function FileTree({ tree, selectedPath, onSelectPath, selectedImageId, onSelectFile }: FileTreeProps) {
24 | return (
25 |
26 |
33 |
34 | );
35 | }
36 |
37 | interface RecursiveTreeProps {
38 | node: FileTreeNode;
39 | selectedPath: string;
40 | onSelectPath: (path: string) => void;
41 | selectedImageId: number | null;
42 | onSelectFile: (id: number) => void;
43 | }
44 |
45 | function RecursiveTree({
46 | node,
47 | selectedPath,
48 | onSelectPath,
49 | selectedImageId,
50 | onSelectFile,
51 | }: RecursiveTreeProps) {
52 | const [isOpen, setIsOpen] = React.useState(false);
53 | const isFolderSelected = selectedPath === node.path;
54 | const isParentOfSelected = selectedPath.startsWith(`${node.path}/`);
55 |
56 | React.useEffect(() => {
57 | if (isParentOfSelected) {
58 | setIsOpen(true);
59 | }
60 | }, [selectedPath, isParentOfSelected]);
61 |
62 | if (node.type === 'file') {
63 | const isFileSelected = selectedImageId === node.id;
64 | return (
65 | onSelectFile(node.id!)}
72 | >
73 |
74 | {node.name}
75 |
76 | );
77 | }
78 |
79 | // If the folder has no children, render it as a simple, non-collapsible item.
80 | if (node.children.length === 0) {
81 | return (
82 | onSelectPath(node.path)}
89 | >
90 |
91 | {node.name}
92 |
93 | );
94 | }
95 |
96 | // If the folder has children, render it as a collapsible item.
97 | return (
98 |
99 | onSelectPath(node.path)}
106 | >
107 | {isOpen ? (
108 |
109 | ) : (
110 |
111 | )}
112 | {node.name}
113 |
114 |
115 | {node.children.map((child) => (
116 |
124 | ))}
125 |
126 |
127 | );
128 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ComfyViewer
2 |
3 | **A local-first, in-browser image and metadata viewer for your AI-generated images, with a special focus on ComfyUI workflows.**
4 |
5 | [](https://opensource.org/licenses/Apache-2.0)
6 |
7 | ComfyViewer is a tool designed for artists, developers, and enthusiasts who work with AI image generation tools. It allows you to load an entire folder of images directly into your browser, where you can browse, search, and inspect the rich metadata embedded within them. Your files are never uploaded to a server; all processing and storage happens locally on your machine using your browser's own database.
8 |
9 | ## Screenshots
10 | 
11 | *ComfyViewer*
12 |
13 | 
14 | *Full image view*
15 |
16 | 
17 | *Workflow View*
18 |
19 | ## ✨ Features
20 |
21 | - **100% Local & Private**: Your images are processed and stored in your browser's IndexedDB. They never leave your computer.
22 | - **Efficient & Fast**: Built with performance in mind, using lazy loading for images and an indexed database for quick searches.
23 | - **Rich Metadata Parsing**: Automatically extracts and displays detailed generation data from ComfyUI-generated images, including prompts, seeds, samplers, models, LoRAs, and the full JSON workflow.
24 | - **Workflow View**: Quickly view the workflow used to generate the image
25 | - **Advanced Search & Filtering**: Quickly find images by filtering on any metadata field, such as prompt keywords, model names, or sampler types.
26 | - **Familiar File-Tree Interface**: Navigate your image folders with a classic file-tree structure, just like on your desktop.
27 | - **Customizable Layout**: Features resizable panels and a customizable grid view to tailor the interface to your liking.
28 | - **Arrow Keys Support**: Quickly cycle through your images to analyze quickly
29 |
30 | ## 🛠️ Tech Stack
31 |
32 | ComfyViewer is built with a modern, simple, and effective tech stack:
33 |
34 | - **Framework**: [Next.js](https://nextjs.org/) (App Router)
35 | - **Language**: [TypeScript](https://www.typescriptlang.org/)
36 | - **Styling**: [Tailwind CSS](https://tailwindcss.com/)
37 | - **UI Components**: [Shadcn/UI](https://ui.shadcn.com/)
38 | - **Local Storage**: [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) for all image and metadata storage.
39 | - **Icons**: [Lucide React](https://lucide.dev/)
40 |
41 | ## 🚀 Getting Started
42 |
43 | To run ComfyViewer on your local machine, follow these steps.
44 |
45 | ### Prerequisites
46 |
47 | - [Node.js](https://nodejs.org/en/) (v18 or later is recommended)
48 | - A package manager like `npm`, `yarn`, `pnpm`, or `bun`.
49 |
50 | ### Installation
51 |
52 | In your project terminal, clone ComfyViewer:
53 | ```bash
54 | git clone https://github.com/christian-saldana/ComfyViewer
55 | ```
56 |
57 | Then install the necessary dependencies:
58 | ```bash
59 | npm install
60 | # or
61 | yarn install
62 | # or
63 | pnpm install
64 | ```
65 |
66 | ### Running the Application
67 |
68 | Once the dependencies are installed, run the server:
69 | ```bash
70 | npm run dev
71 | ```
72 | or
73 | ```bash
74 | npm run build
75 | npm run start
76 | ```
77 |
78 |
79 | Now, open [http://localhost:3000](http://localhost:3000) in your browser to start using ComfyViewer.
80 |
81 | ## 📖 How to Use
82 |
83 | 1. Once the application is running, click the **"Select Folder"** button.
84 | 2. Your browser's file dialog will open. Choose a directory that contains your AI-generated images.
85 | 3. The application will process the folder, extract metadata, and store everything in your browser. This might take a moment for very large folders.
86 | 4. Once loaded, you can browse your images, click on them to view metadata, and use the search tools to filter your collection.
87 |
88 | ## 🤝 Contributing
89 |
90 | Contributions are welcome! If you have ideas for new features, bug fixes, or improvements, please feel free to open an issue or submit a pull request.
91 |
92 | ## 📄 License
93 |
94 | This project is open-source and available under the license specified in the [LICENSE](LICENSE) file.
95 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as DialogPrimitive from "@radix-ui/react-dialog"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Dialog = DialogPrimitive.Root
11 |
12 | const DialogTrigger = DialogPrimitive.Trigger
13 |
14 | const DialogPortal = DialogPrimitive.Portal
15 |
16 | const DialogClose = DialogPrimitive.Close
17 |
18 | const DialogOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
32 |
33 | const DialogContent = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, children, ...props }, ref) => (
37 |
38 |
39 |
47 | {children}
48 |
49 |
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/gallery-pagination.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import {
6 | Pagination,
7 | PaginationContent,
8 | PaginationEllipsis,
9 | PaginationItem,
10 | PaginationLink,
11 | PaginationNext,
12 | PaginationPrevious,
13 | } from "@/components/ui/pagination";
14 | import {
15 | Select,
16 | SelectContent,
17 | SelectItem,
18 | SelectTrigger,
19 | SelectValue,
20 | } from "@/components/ui/select";
21 |
22 | import { Label } from "./ui/label";
23 |
24 | interface GalleryPaginationProps {
25 | currentPage: number;
26 | totalPages: number;
27 | onPageChange: (page: number) => void;
28 | itemsPerPage: number;
29 | onItemsPerPageChange: (value: number) => void;
30 | itemsPerPageOptions: number[];
31 | }
32 |
33 | const generatePagination = (currentPage: number, totalPages: number) => {
34 | if (totalPages <= 7) {
35 | return Array.from({ length: totalPages }, (_, i) => i + 1);
36 | }
37 |
38 | if (currentPage <= 4) {
39 | return [1, 2, 3, 4, 5, "...", totalPages];
40 | }
41 |
42 | if (currentPage >= totalPages - 3) {
43 | return [
44 | 1,
45 | "...",
46 | totalPages - 4,
47 | totalPages - 3,
48 | totalPages - 2,
49 | totalPages - 1,
50 | totalPages,
51 | ];
52 | }
53 |
54 | return [
55 | 1,
56 | "...",
57 | currentPage - 1,
58 | currentPage,
59 | currentPage + 1,
60 | "...",
61 | totalPages,
62 | ];
63 | };
64 |
65 | export function GalleryPagination({
66 | currentPage,
67 | totalPages,
68 | onPageChange,
69 | itemsPerPage,
70 | onItemsPerPageChange,
71 | itemsPerPageOptions,
72 | }: GalleryPaginationProps) {
73 | const paginationItems = generatePagination(currentPage, totalPages);
74 |
75 | return (
76 |
77 |
78 |
79 | Per Page:
80 |
81 | onItemsPerPageChange(Number(value))}
84 | >
85 |
86 |
87 |
88 |
89 | {itemsPerPageOptions.map((option) => (
90 |
91 | {option}
92 |
93 | ))}
94 |
95 |
96 |
97 |
98 |
99 |
100 | {
103 | e.preventDefault();
104 | onPageChange(Math.max(1, currentPage - 1));
105 | }}
106 | className={
107 | currentPage === 1 ? "pointer-events-none opacity-50" : ""
108 | }
109 | />
110 |
111 | {paginationItems.map((item, index) => (
112 |
113 | {item === "..." ? (
114 |
115 | ) : (
116 | {
119 | e.preventDefault();
120 | onPageChange(item as number);
121 | }}
122 | isActive={currentPage === item}
123 | >
124 | {item}
125 |
126 | )}
127 |
128 | ))}
129 |
130 | {
133 | e.preventDefault();
134 | onPageChange(Math.min(totalPages, currentPage + 1));
135 | }}
136 | className={
137 | currentPage === totalPages
138 | ? "pointer-events-none opacity-50"
139 | : ""
140 | }
141 | />
142 |
143 |
144 |
145 |
146 | Page {currentPage} of {totalPages}
147 |
148 |
149 | );
150 | }
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as SheetPrimitive from "@radix-ui/react-dialog"
6 | import { cva, type VariantProps } from "class-variance-authority"
7 | import { X } from "lucide-react"
8 |
9 | import { cn } from "@/lib/utils"
10 |
11 | const Sheet = SheetPrimitive.Root
12 |
13 | const SheetTrigger = SheetPrimitive.Trigger
14 |
15 | const SheetClose = SheetPrimitive.Close
16 |
17 | const SheetPortal = SheetPrimitive.Portal
18 |
19 | const SheetOverlay = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, ...props }, ref) => (
23 |
31 | ))
32 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
33 |
34 | const sheetVariants = cva(
35 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
36 | {
37 | variants: {
38 | side: {
39 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
40 | bottom:
41 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
42 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
43 | right:
44 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
45 | },
46 | },
47 | defaultVariants: {
48 | side: "right",
49 | },
50 | }
51 | )
52 |
53 | interface SheetContentProps
54 | extends React.ComponentPropsWithoutRef,
55 | VariantProps { }
56 |
57 | const SheetContent = React.forwardRef<
58 | React.ElementRef,
59 | SheetContentProps
60 | >(({ side = "right", className, children, ...props }, ref) => (
61 |
62 |
63 |
68 |
69 |
70 | Close
71 |
72 | {children}
73 |
74 |
75 | ))
76 | SheetContent.displayName = SheetPrimitive.Content.displayName
77 |
78 | const SheetHeader = ({
79 | className,
80 | ...props
81 | }: React.HTMLAttributes) => (
82 |
89 | )
90 | SheetHeader.displayName = "SheetHeader"
91 |
92 | const SheetFooter = ({
93 | className,
94 | ...props
95 | }: React.HTMLAttributes) => (
96 |
103 | )
104 | SheetFooter.displayName = "SheetFooter"
105 |
106 | const SheetTitle = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ className, ...props }, ref) => (
110 |
115 | ))
116 | SheetTitle.displayName = SheetPrimitive.Title.displayName
117 |
118 | const SheetDescription = React.forwardRef<
119 | React.ElementRef,
120 | React.ComponentPropsWithoutRef
121 | >(({ className, ...props }, ref) => (
122 |
127 | ))
128 | SheetDescription.displayName = SheetPrimitive.Description.displayName
129 |
130 | export {
131 | Sheet,
132 | SheetPortal,
133 | SheetOverlay,
134 | SheetTrigger,
135 | SheetClose,
136 | SheetContent,
137 | SheetHeader,
138 | SheetFooter,
139 | SheetTitle,
140 | SheetDescription,
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as LabelPrimitive from "@radix-ui/react-label"
6 | import { Slot } from "@radix-ui/react-slot"
7 | import {
8 | Controller,
9 | FormProvider,
10 | useFormContext,
11 | type ControllerProps,
12 | type FieldPath,
13 | type FieldValues,
14 | } from "react-hook-form"
15 |
16 | import { Label } from "@/components/ui/label"
17 | import { cn } from "@/lib/utils"
18 |
19 | const Form = FormProvider
20 |
21 | type FormFieldContextValue<
22 | TFieldValues extends FieldValues = FieldValues,
23 | TName extends FieldPath = FieldPath
24 | > = {
25 | name: TName
26 | }
27 |
28 | const FormFieldContext = React.createContext(
29 | {} as FormFieldContextValue
30 | )
31 |
32 | const FormField = <
33 | TFieldValues extends FieldValues = FieldValues,
34 | TName extends FieldPath = FieldPath
35 | >({
36 | ...props
37 | }: ControllerProps) => {
38 | return (
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | const useFormField = () => {
46 | const fieldContext = React.useContext(FormFieldContext)
47 | const itemContext = React.useContext(FormItemContext)
48 | const { getFieldState, formState } = useFormContext()
49 |
50 | const fieldState = getFieldState(fieldContext.name, formState)
51 |
52 | if (!fieldContext) {
53 | throw new Error("useFormField should be used within ")
54 | }
55 |
56 | const { id } = itemContext
57 |
58 | return {
59 | id,
60 | name: fieldContext.name,
61 | formItemId: `${id}-form-item`,
62 | formDescriptionId: `${id}-form-item-description`,
63 | formMessageId: `${id}-form-item-message`,
64 | ...fieldState,
65 | }
66 | }
67 |
68 | type FormItemContextValue = {
69 | id: string
70 | }
71 |
72 | const FormItemContext = React.createContext(
73 | {} as FormItemContextValue
74 | )
75 |
76 | const FormItem = React.forwardRef<
77 | HTMLDivElement,
78 | React.HTMLAttributes
79 | >(({ className, ...props }, ref) => {
80 | const id = React.useId()
81 |
82 | return (
83 |
84 |
85 |
86 | )
87 | })
88 | FormItem.displayName = "FormItem"
89 |
90 | const FormLabel = React.forwardRef<
91 | React.ElementRef,
92 | React.ComponentPropsWithoutRef
93 | >(({ className, ...props }, ref) => {
94 | const { error, formItemId } = useFormField()
95 |
96 | return (
97 |
103 | )
104 | })
105 | FormLabel.displayName = "FormLabel"
106 |
107 | const FormControl = React.forwardRef<
108 | React.ElementRef,
109 | React.ComponentPropsWithoutRef
110 | >(({ ...props }, ref) => {
111 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
112 |
113 | return (
114 |
125 | )
126 | })
127 | FormControl.displayName = "FormControl"
128 |
129 | const FormDescription = React.forwardRef<
130 | HTMLParagraphElement,
131 | React.HTMLAttributes
132 | >(({ className, ...props }, ref) => {
133 | const { formDescriptionId } = useFormField()
134 |
135 | return (
136 |
142 | )
143 | })
144 | FormDescription.displayName = "FormDescription"
145 |
146 | const FormMessage = React.forwardRef<
147 | HTMLParagraphElement,
148 | React.HTMLAttributes
149 | >(({ className, children, ...props }, ref) => {
150 | const { error, formMessageId } = useFormField()
151 | const body = error ? String(error?.message ?? "") : children
152 |
153 | if (!body) {
154 | return null
155 | }
156 |
157 | return (
158 |
164 | {body}
165 |
166 | )
167 | })
168 | FormMessage.displayName = "FormMessage"
169 |
170 | export {
171 | useFormField,
172 | Form,
173 | FormItem,
174 | FormLabel,
175 | FormControl,
176 | FormDescription,
177 | FormMessage,
178 | FormField,
179 | }
180 |
--------------------------------------------------------------------------------
/src/components/workflow-viewer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { Expand, ZoomIn, ZoomOut } from "lucide-react";
6 | import { useTheme } from "next-themes";
7 | import ReactFlow, {
8 | Background,
9 | ConnectionLineType,
10 | Edge,
11 | Node,
12 | Panel,
13 | ReactFlowInstance,
14 | applyEdgeChanges,
15 | applyNodeChanges,
16 | EdgeChange,
17 | NodeChange,
18 | } from "reactflow";
19 |
20 | import "reactflow/dist/style.css";
21 | import { Button } from "@/components/ui/button";
22 | import {
23 | Tooltip,
24 | TooltipContent,
25 | TooltipProvider,
26 | TooltipTrigger,
27 | } from "@/components/ui/tooltip";
28 | import { ComfyUIWorkflow, parseComfyWorkflow } from "@/lib/comfy-workflow-parser";
29 |
30 | import ComfyNode from "./comfy-node";
31 |
32 | interface WorkflowViewerProps {
33 | workflowJson: Record | null;
34 | }
35 |
36 | export function WorkflowViewer({ workflowJson }: WorkflowViewerProps) {
37 | const [nodes, setNodes] = React.useState([]);
38 | const [edges, setEdges] = React.useState([]);
39 | const [reactFlowInstance, setReactFlowInstance] =
40 | React.useState(null);
41 | const { theme } = useTheme();
42 |
43 | React.useEffect(() => {
44 | if (!workflowJson) {
45 | setNodes([]);
46 | setEdges([]);
47 | return;
48 | }
49 | try {
50 | if (
51 | typeof workflowJson === "object" &&
52 | workflowJson !== null &&
53 | Object.values(workflowJson).some(
54 | (v) => v && typeof v === "object" && "class_type" in v
55 | )
56 | ) {
57 | const workflowNodes = Object.entries(workflowJson)
58 | .filter(
59 | ([k, v]) =>
60 | !isNaN(Number(k)) &&
61 | v &&
62 | typeof v === "object" &&
63 | "class_type" in v
64 | )
65 | .map(([k, v]) => [k, v]);
66 | const workflowObj = Object.fromEntries(workflowNodes);
67 | const { nodes: dagreNodes, edges: dagreEdges } = parseComfyWorkflow(
68 | workflowObj as unknown as ComfyUIWorkflow
69 | );
70 | dagreNodes.forEach((n) => (n.type = "comfy"));
71 | setNodes(dagreNodes);
72 | setEdges(dagreEdges);
73 | } else {
74 | setNodes([]);
75 | setEdges([]);
76 | }
77 | } catch {
78 | setNodes([]);
79 | setEdges([]);
80 | }
81 | }, [workflowJson]);
82 |
83 | const nodeTypes = React.useMemo(() => ({ comfy: ComfyNode }), []);
84 |
85 | const onNodesChange = React.useCallback(
86 | (changes: NodeChange[]) =>
87 | setNodes((nds) => applyNodeChanges(changes, nds)),
88 | [setNodes]
89 | );
90 | const onEdgesChange = React.useCallback(
91 | (changes: EdgeChange[]) =>
92 | setEdges((eds) => applyEdgeChanges(changes, eds)),
93 | [setEdges]
94 | );
95 |
96 | const handleFitView = () => reactFlowInstance?.fitView({ padding: 0.2 });
97 | const handleZoomIn = () => reactFlowInstance?.zoomIn();
98 | const handleZoomOut = () => reactFlowInstance?.zoomOut();
99 |
100 | return (
101 |
102 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 | Zoom In
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | Zoom Out
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 | Fit View
141 |
142 |
143 |
144 |
145 |
146 |
147 | );
148 | }
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
6 |
7 | import { buttonVariants } from "@/components/ui/button"
8 | import { cn } from "@/lib/utils"
9 |
10 | const AlertDialog = AlertDialogPrimitive.Root
11 |
12 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
13 |
14 | const AlertDialogPortal = AlertDialogPrimitive.Portal
15 |
16 | const AlertDialogOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ))
29 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
30 |
31 | const AlertDialogContent = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef
34 | >(({ className, ...props }, ref) => (
35 |
36 |
37 |
45 |
46 | ))
47 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
48 |
49 | const AlertDialogHeader = ({
50 | className,
51 | ...props
52 | }: React.HTMLAttributes) => (
53 |
60 | )
61 | AlertDialogHeader.displayName = "AlertDialogHeader"
62 |
63 | const AlertDialogFooter = ({
64 | className,
65 | ...props
66 | }: React.HTMLAttributes) => (
67 |
74 | )
75 | AlertDialogFooter.displayName = "AlertDialogFooter"
76 |
77 | const AlertDialogTitle = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef
80 | >(({ className, ...props }, ref) => (
81 |
86 | ))
87 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
88 |
89 | const AlertDialogDescription = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | AlertDialogDescription.displayName =
100 | AlertDialogPrimitive.Description.displayName
101 |
102 | const AlertDialogAction = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
113 |
114 | const AlertDialogCancel = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, ...props }, ref) => (
118 |
127 | ))
128 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
129 |
130 | export {
131 | AlertDialog,
132 | AlertDialogPortal,
133 | AlertDialogOverlay,
134 | AlertDialogTrigger,
135 | AlertDialogContent,
136 | AlertDialogHeader,
137 | AlertDialogFooter,
138 | AlertDialogTitle,
139 | AlertDialogDescription,
140 | AlertDialogAction,
141 | AlertDialogCancel,
142 | }
143 |
--------------------------------------------------------------------------------
/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import { type DialogProps } from "@radix-ui/react-dialog"
6 | import { Command as CommandPrimitive } from "cmdk"
7 | import { Search } from "lucide-react"
8 |
9 | import { Dialog, DialogContent } from "@/components/ui/dialog"
10 | import { cn } from "@/lib/utils"
11 |
12 | const Command = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | Command.displayName = CommandPrimitive.displayName
26 |
27 | const CommandDialog = ({ children, ...props }: DialogProps) => {
28 | return (
29 |
30 |
31 |
32 | {children}
33 |
34 |
35 |
36 | )
37 | }
38 |
39 | const CommandInput = React.forwardRef<
40 | React.ElementRef,
41 | React.ComponentPropsWithoutRef
42 | >(({ className, ...props }, ref) => (
43 |
44 |
45 |
53 |
54 | ))
55 |
56 | CommandInput.displayName = CommandPrimitive.Input.displayName
57 |
58 | const CommandList = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
67 | ))
68 |
69 | CommandList.displayName = CommandPrimitive.List.displayName
70 |
71 | const CommandEmpty = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >((props, ref) => (
75 |
80 | ))
81 |
82 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
83 |
84 | const CommandGroup = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 |
98 | CommandGroup.displayName = CommandPrimitive.Group.displayName
99 |
100 | const CommandSeparator = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
109 | ))
110 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
111 |
112 | const CommandItem = React.forwardRef<
113 | React.ElementRef,
114 | React.ComponentPropsWithoutRef
115 | >(({ className, ...props }, ref) => (
116 |
124 | ))
125 |
126 | CommandItem.displayName = CommandPrimitive.Item.displayName
127 |
128 | const CommandShortcut = ({
129 | className,
130 | ...props
131 | }: React.HTMLAttributes) => {
132 | return (
133 |
140 | )
141 | }
142 | CommandShortcut.displayName = "CommandShortcut"
143 |
144 | export {
145 | Command,
146 | CommandDialog,
147 | CommandInput,
148 | CommandList,
149 | CommandEmpty,
150 | CommandGroup,
151 | CommandItem,
152 | CommandShortcut,
153 | CommandSeparator,
154 | }
155 |
--------------------------------------------------------------------------------
/src/components/ui/navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
4 | import { cva } from "class-variance-authority"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const NavigationMenu = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, children, ...props }, ref) => (
13 |
21 | {children}
22 |
23 |
24 | ))
25 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
26 |
27 | const NavigationMenuList = React.forwardRef<
28 | React.ElementRef,
29 | React.ComponentPropsWithoutRef
30 | >(({ className, ...props }, ref) => (
31 |
39 | ))
40 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
41 |
42 | const NavigationMenuItem = NavigationMenuPrimitive.Item
43 |
44 | const navigationMenuTriggerStyle = cva(
45 | "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
46 | )
47 |
48 | const NavigationMenuTrigger = React.forwardRef<
49 | React.ElementRef,
50 | React.ComponentPropsWithoutRef
51 | >(({ className, children, ...props }, ref) => (
52 |
57 | {children}{" "}
58 |
62 |
63 | ))
64 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
65 |
66 | const NavigationMenuContent = React.forwardRef<
67 | React.ElementRef,
68 | React.ComponentPropsWithoutRef
69 | >(({ className, ...props }, ref) => (
70 |
78 | ))
79 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
80 |
81 | const NavigationMenuLink = NavigationMenuPrimitive.Link
82 |
83 | const NavigationMenuViewport = React.forwardRef<
84 | React.ElementRef,
85 | React.ComponentPropsWithoutRef
86 | >(({ className, ...props }, ref) => (
87 |
88 |
96 |
97 | ))
98 | NavigationMenuViewport.displayName =
99 | NavigationMenuPrimitive.Viewport.displayName
100 |
101 | const NavigationMenuIndicator = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
113 |
114 |
115 | ))
116 | NavigationMenuIndicator.displayName =
117 | NavigationMenuPrimitive.Indicator.displayName
118 |
119 | export {
120 | navigationMenuTriggerStyle,
121 | NavigationMenu,
122 | NavigationMenuList,
123 | NavigationMenuItem,
124 | NavigationMenuContent,
125 | NavigationMenuTrigger,
126 | NavigationMenuLink,
127 | NavigationMenuIndicator,
128 | NavigationMenuViewport,
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/image-gallery.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { ImageIcon } from "lucide-react";
6 |
7 | import { ComfyViewerDialog } from "@/components/comfy-viewer-dialog";
8 | import { GalleryPagination } from "@/components/gallery-pagination";
9 | import { LazyImage } from "@/components/lazy-image";
10 | import { StoredImage } from "@/lib/image-db";
11 | import { cn } from "@/lib/utils";
12 | import { ITEMS_PER_PAGE_OPTIONS } from "@/hooks/use-pagination"; // Import here
13 |
14 | interface ImageGalleryProps {
15 | files: StoredImage[];
16 | allImageMetadata: StoredImage[];
17 | selectedImageId: number | null;
18 | onSelectImage: (id: number) => void;
19 | gridCols: number;
20 | currentPage: number;
21 | totalPages: number;
22 | onPageChange: (page: number) => void;
23 | itemsPerPage: number;
24 | onItemsPerPageChange: (value: number) => void;
25 | itemsPerPageOptions: number[];
26 | totalImagesCount: number;
27 | folderPath: string; // New prop for the base folder path
28 | }
29 |
30 | export function ImageGallery({
31 | files,
32 | allImageMetadata,
33 | selectedImageId,
34 | onSelectImage,
35 | gridCols,
36 | currentPage,
37 | totalPages,
38 | onPageChange,
39 | itemsPerPage,
40 | onItemsPerPageChange,
41 | totalImagesCount,
42 | folderPath, // Destructure new prop
43 | }: ImageGalleryProps) {
44 | const [fullscreenImageSrc, setFullscreenImageSrc] = React.useState(null);
45 | const [fullscreenImageAlt, setFullscreenImageAlt] = React.useState("");
46 | const [isViewerOpen, setIsViewerOpen] = React.useState(false);
47 |
48 | const openViewer = async (image: StoredImage) => {
49 | if (!image?.fullPath) return
50 | // Construct the API URL for the full-screen image
51 | const imageUrl = `/api/image?path=${encodeURIComponent(image.fullPath)}`;
52 | setFullscreenImageSrc(imageUrl);
53 | setFullscreenImageAlt(image.name);
54 | setIsViewerOpen(true);
55 | };
56 |
57 | const handleDoubleClick = (image: StoredImage) => {
58 | onSelectImage(image.id);
59 | openViewer(image);
60 | };
61 |
62 | React.useEffect(() => {
63 | // When selectedImageId changes and viewer is open, update the fullscreen image
64 | if (isViewerOpen && selectedImageId !== null) {
65 | const image = allImageMetadata.find((f) => f.id === selectedImageId);
66 | if (image && image.fullPath) {
67 | const imageUrl = `/api/image?path=${encodeURIComponent(image.fullPath)}`;
68 | setFullscreenImageSrc(imageUrl);
69 | setFullscreenImageAlt(image.name);
70 | }
71 | }
72 | // eslint-disable-next-line react-hooks/exhaustive-deps
73 | }, [selectedImageId, isViewerOpen, allImageMetadata, folderPath]); // Add folderPath to dependencies
74 |
75 | React.useEffect(() => {
76 | // When viewer closes, clear the fullscreen image src
77 | if (!isViewerOpen && fullscreenImageSrc) {
78 | setFullscreenImageSrc(null);
79 | setFullscreenImageAlt("");
80 | }
81 | }, [isViewerOpen, fullscreenImageSrc]);
82 |
83 | if (files.length === 0) {
84 | return (
85 |
86 |
87 |
No Images to Display
88 |
89 | Select a folder with images or adjust your filters.
90 |
91 |
92 | );
93 | }
94 |
95 | const isSingleColumn = gridCols === 1;
96 |
97 | return (
98 | <>
99 |
100 |
101 |
102 |
103 | {totalImagesCount} {totalImagesCount === 1 ? "image" : "images"}
104 |
105 |
106 |
110 | {files.map((image) => (
111 |
onSelectImage(image.id)}
121 | onDoubleClick={() => handleDoubleClick(image)}
122 | >
123 |
133 |
134 |
135 | {image.name}
136 |
137 |
138 |
139 | ))}
140 |
141 |
142 |
150 |
151 |
157 | >
158 | );
159 | }
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as SelectPrimitive from "@radix-ui/react-select"
6 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Select = SelectPrimitive.Root
11 |
12 | const SelectGroup = SelectPrimitive.Group
13 |
14 | const SelectValue = SelectPrimitive.Value
15 |
16 | const SelectTrigger = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, children, ...props }, ref) => (
20 | span]:line-clamp-1",
24 | className
25 | )}
26 | {...props}
27 | >
28 | {children}
29 |
30 |
31 |
32 |
33 | ))
34 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
35 |
36 | const SelectScrollUpButton = React.forwardRef<
37 | React.ElementRef,
38 | React.ComponentPropsWithoutRef
39 | >(({ className, ...props }, ref) => (
40 |
48 |
49 |
50 | ))
51 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
52 |
53 | const SelectScrollDownButton = React.forwardRef<
54 | React.ElementRef,
55 | React.ComponentPropsWithoutRef
56 | >(({ className, ...props }, ref) => (
57 |
65 |
66 |
67 | ))
68 | SelectScrollDownButton.displayName =
69 | SelectPrimitive.ScrollDownButton.displayName
70 |
71 | const SelectContent = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, children, position = "popper", ...props }, ref) => (
75 |
76 |
87 |
88 |
95 | {children}
96 |
97 |
98 |
99 |
100 | ))
101 | SelectContent.displayName = SelectPrimitive.Content.displayName
102 |
103 | const SelectLabel = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | SelectLabel.displayName = SelectPrimitive.Label.displayName
114 |
115 | const SelectItem = React.forwardRef<
116 | React.ElementRef,
117 | React.ComponentPropsWithoutRef
118 | >(({ className, children, ...props }, ref) => (
119 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/src/components/sidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import {
6 | RefreshCw,
7 | Search,
8 | SlidersHorizontal,
9 | Trash2,
10 | } from "lucide-react";
11 |
12 | import { AdvancedSearchForm, AdvancedSearchState } from "@/components/advanced-search-form";
13 | import { FileTree } from "@/components/file-tree";
14 | import { Button } from "@/components/ui/button";
15 | import {
16 | Collapsible,
17 | CollapsibleContent,
18 | CollapsibleTrigger,
19 | } from "@/components/ui/collapsible";
20 | import { Input } from "@/components/ui/input";
21 | import { Label } from "@/components/ui/label";
22 | import { ScrollArea } from "@/components/ui/scroll-area";
23 | import { Switch } from "@/components/ui/switch";
24 | import {
25 | Tooltip,
26 | TooltipContent,
27 | TooltipProvider,
28 | TooltipTrigger,
29 | } from "@/components/ui/tooltip";
30 | import { FileTreeNode } from "@/lib/file-tree";
31 |
32 | interface SidebarProps {
33 | isLoading: boolean;
34 | onRefreshClick: () => void;
35 | filterQuery: string;
36 | onFilterQueryChange: (query: string) => void;
37 | advancedSearchState: AdvancedSearchState;
38 | onAdvancedSearchChange: (state: Partial) => void;
39 | onAdvancedSearchReset: () => void;
40 | viewSubfolders: boolean;
41 | onViewSubfoldersChange: (value: boolean) => void;
42 | onClearAllData: () => void;
43 | fileTree: FileTreeNode | null;
44 | selectedPath: string;
45 | onSelectPath: (path: string) => void;
46 | selectedImageId: number | null;
47 | onSelectFile: (id: number) => void;
48 | folderPathInput: string;
49 | onFolderPathInputChange: (path: string) => void;
50 | onLoadFolderPath: () => void;
51 | }
52 |
53 | export function Sidebar({
54 | isLoading,
55 | onRefreshClick,
56 | filterQuery,
57 | onFilterQueryChange,
58 | advancedSearchState,
59 | onAdvancedSearchChange,
60 | onAdvancedSearchReset,
61 | viewSubfolders,
62 | onViewSubfoldersChange,
63 | onClearAllData,
64 | fileTree,
65 | selectedPath,
66 | onSelectPath,
67 | selectedImageId,
68 | onSelectFile,
69 | folderPathInput,
70 | onFolderPathInputChange,
71 | onLoadFolderPath,
72 | }: SidebarProps) {
73 | return (
74 |
75 |
76 |
77 |
Folders
78 |
79 |
Folder Path
80 |
81 | onFolderPathInputChange(e.target.value)}
87 | disabled={isLoading}
88 | />
89 |
90 | Load
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | Refresh
101 |
102 |
103 |
104 | {"Scan for new images in the selected folder."}
105 |
106 |
107 |
108 |
109 |
110 |
111 | onFilterQueryChange(e.target.value)}
116 | disabled={isLoading}
117 | />
118 |
119 |
120 |
121 |
122 |
123 | Advanced Search
124 |
125 |
126 |
127 |
132 |
133 |
134 |
135 |
136 | Clear All Data
137 |
138 |
139 |
140 |
141 |
142 |
148 |
152 | View Subfolders
153 |
154 |
155 |
156 |
157 | Show images from all subfolders.
158 |
159 |
160 |
161 |
162 |
163 |
164 | {fileTree ? (
165 |
172 | ) : (
173 |
174 | Select a folder to view its structure.
175 |
176 | )}
177 |
178 |
179 | );
180 | }
--------------------------------------------------------------------------------
/src/components/ui/carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import useEmblaCarousel, {
6 | type UseEmblaCarouselType,
7 | } from "embla-carousel-react"
8 | import { ArrowLeft, ArrowRight } from "lucide-react"
9 |
10 | import { Button } from "@/components/ui/button"
11 | import { cn } from "@/lib/utils"
12 |
13 | type CarouselApi = UseEmblaCarouselType[1]
14 | type UseCarouselParameters = Parameters
15 | type CarouselOptions = UseCarouselParameters[0]
16 | type CarouselPlugin = UseCarouselParameters[1]
17 |
18 | type CarouselProps = {
19 | opts?: CarouselOptions
20 | plugins?: CarouselPlugin
21 | orientation?: "horizontal" | "vertical"
22 | setApi?: (api: CarouselApi) => void
23 | }
24 |
25 | type CarouselContextProps = {
26 | carouselRef: ReturnType[0]
27 | api: ReturnType[1]
28 | scrollPrev: () => void
29 | scrollNext: () => void
30 | canScrollPrev: boolean
31 | canScrollNext: boolean
32 | } & CarouselProps
33 |
34 | const CarouselContext = React.createContext(null)
35 |
36 | function useCarousel() {
37 | const context = React.useContext(CarouselContext)
38 |
39 | if (!context) {
40 | throw new Error("useCarousel must be used within a ")
41 | }
42 |
43 | return context
44 | }
45 |
46 | const Carousel = React.forwardRef<
47 | HTMLDivElement,
48 | React.HTMLAttributes & CarouselProps
49 | >(
50 | (
51 | {
52 | orientation = "horizontal",
53 | opts,
54 | setApi,
55 | plugins,
56 | className,
57 | children,
58 | ...props
59 | },
60 | ref
61 | ) => {
62 | const [carouselRef, api] = useEmblaCarousel(
63 | {
64 | ...opts,
65 | axis: orientation === "horizontal" ? "x" : "y",
66 | },
67 | plugins
68 | )
69 | const [canScrollPrev, setCanScrollPrev] = React.useState(false)
70 | const [canScrollNext, setCanScrollNext] = React.useState(false)
71 |
72 | const onSelect = React.useCallback((api: CarouselApi) => {
73 | if (!api) {
74 | return
75 | }
76 |
77 | setCanScrollPrev(api.canScrollPrev())
78 | setCanScrollNext(api.canScrollNext())
79 | }, [])
80 |
81 | const scrollPrev = React.useCallback(() => {
82 | api?.scrollPrev()
83 | }, [api])
84 |
85 | const scrollNext = React.useCallback(() => {
86 | api?.scrollNext()
87 | }, [api])
88 |
89 | const handleKeyDown = React.useCallback(
90 | (event: React.KeyboardEvent) => {
91 | if (event.key === "ArrowLeft") {
92 | event.preventDefault()
93 | scrollPrev()
94 | } else if (event.key === "ArrowRight") {
95 | event.preventDefault()
96 | scrollNext()
97 | }
98 | },
99 | [scrollPrev, scrollNext]
100 | )
101 |
102 | React.useEffect(() => {
103 | if (!api || !setApi) {
104 | return
105 | }
106 |
107 | setApi(api)
108 | }, [api, setApi])
109 |
110 | React.useEffect(() => {
111 | if (!api) {
112 | return
113 | }
114 |
115 | onSelect(api)
116 | api.on("reInit", onSelect)
117 | api.on("select", onSelect)
118 |
119 | return () => {
120 | api?.off("select", onSelect)
121 | }
122 | }, [api, onSelect])
123 |
124 | return (
125 |
138 |
146 | {children}
147 |
148 |
149 | )
150 | }
151 | )
152 | Carousel.displayName = "Carousel"
153 |
154 | const CarouselContent = React.forwardRef<
155 | HTMLDivElement,
156 | React.HTMLAttributes
157 | >(({ className, ...props }, ref) => {
158 | const { carouselRef, orientation } = useCarousel()
159 |
160 | return (
161 |
172 | )
173 | })
174 | CarouselContent.displayName = "CarouselContent"
175 |
176 | const CarouselItem = React.forwardRef<
177 | HTMLDivElement,
178 | React.HTMLAttributes
179 | >(({ className, ...props }, ref) => {
180 | const { orientation } = useCarousel()
181 |
182 | return (
183 |
194 | )
195 | })
196 | CarouselItem.displayName = "CarouselItem"
197 |
198 | const CarouselPrevious = React.forwardRef<
199 | HTMLButtonElement,
200 | React.ComponentProps
201 | >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
202 | const { orientation, scrollPrev, canScrollPrev } = useCarousel()
203 |
204 | return (
205 |
220 |
221 | Previous slide
222 |
223 | )
224 | })
225 | CarouselPrevious.displayName = "CarouselPrevious"
226 |
227 | const CarouselNext = React.forwardRef<
228 | HTMLButtonElement,
229 | React.ComponentProps
230 | >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
231 | const { orientation, scrollNext, canScrollNext } = useCarousel()
232 |
233 | return (
234 |
249 |
250 | Next slide
251 |
252 | )
253 | })
254 | CarouselNext.displayName = "CarouselNext"
255 |
256 | export {
257 | type CarouselApi,
258 | Carousel,
259 | CarouselContent,
260 | CarouselItem,
261 | CarouselPrevious,
262 | CarouselNext,
263 | }
264 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
6 | import { Check, ChevronRight, Circle } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ContextMenu = ContextMenuPrimitive.Root
11 |
12 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger
13 |
14 | const ContextMenuGroup = ContextMenuPrimitive.Group
15 |
16 | const ContextMenuPortal = ContextMenuPrimitive.Portal
17 |
18 | const ContextMenuSub = ContextMenuPrimitive.Sub
19 |
20 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
21 |
22 | const ContextMenuSubTrigger = React.forwardRef<
23 | React.ElementRef,
24 | React.ComponentPropsWithoutRef & {
25 | inset?: boolean
26 | }
27 | >(({ className, inset, children, ...props }, ref) => (
28 |
37 | {children}
38 |
39 |
40 | ))
41 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
42 |
43 | const ContextMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
57 |
58 | const ContextMenuContent = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
63 |
71 |
72 | ))
73 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
74 |
75 | const ContextMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ))
91 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
92 |
93 | const ContextMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |