= ({ children }) => {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 |
17 | {children}
18 |
19 |
20 |
21 | >
22 | );
23 | };
24 |
25 | const withLayout = (Component: React.ComponentType
) => {
26 | return (props: P) => (
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default withLayout;
34 |
--------------------------------------------------------------------------------
/src/hooks/useInfiniteScroll.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | interface UseInfiniteScrollProps {
4 | loading: boolean;
5 | hasMore: boolean;
6 | loadMore: () => Promise;
7 | }
8 |
9 | export const useInfiniteScroll = ({
10 | loading,
11 | hasMore,
12 | loadMore,
13 | }: UseInfiniteScrollProps) => {
14 | const observer = useRef(null);
15 |
16 | useEffect(() => {
17 | return () => {
18 | if (observer.current) {
19 | observer.current.disconnect();
20 | }
21 | };
22 | }, []);
23 |
24 | const lastPromptElementRef = (node: HTMLDivElement | null) => {
25 | if (loading || !hasMore) return;
26 | if (observer.current) observer.current.disconnect();
27 |
28 | observer.current = new IntersectionObserver((entries) => {
29 | if (entries[0].isIntersecting) {
30 | loadMore();
31 | }
32 | });
33 |
34 | if (node) observer.current.observe(node);
35 | };
36 |
37 | return lastPromptElementRef;
38 | };
39 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/hooks/useCopy.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Prompt } from "@/types";
3 |
4 | export const useCopy = () => {
5 | const [isCopied, setIsCopied] = useState(false);
6 | const allowedKeys = new Set([
7 | 'controlnet', 'color_grading', 'super_resolution', 'hires_fix', 'inpaint_faces',
8 | 'face_correct', 'face_swap', 'ar', 'denoising_strength', 'controlnet_conditioning_scale',
9 | 'num_images', 'w', 'h' , 'input_image','controlnet_txt2img', 'film_grain', 'backend_version', 'seed', 'steps'
10 | ]);
11 |
12 | const handleCopy = (prompt: Prompt) => {
13 | let text = '';
14 |
15 | // Ensure prompt.text comes first
16 | if (prompt.text) {
17 | text += prompt.text;
18 | }
19 |
20 | // Add other allowed keys
21 | for (const key of Object.keys(prompt) as (keyof Prompt)[]) {
22 | if (key !== "text" && allowedKeys.has(key) && prompt[key]) {
23 | text += ` ${key}=${prompt[key]}`;
24 | }
25 | }
26 |
27 | if (text) {
28 | navigator.clipboard.writeText(text);
29 | setIsCopied(true);
30 | setTimeout(() => setIsCopied(false), 2000);
31 | }
32 | };
33 |
34 | return { isCopied, handleCopy };
35 | };
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | workflow_dispatch:
7 |
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 |
13 | concurrency:
14 | group: "pages"
15 | cancel-in-progress: false
16 |
17 | jobs:
18 | deploy:
19 | environment:
20 | name: github-pages
21 | url: ${{ steps.deployment.outputs.page_url }}
22 | runs-on: ubuntu-latest
23 | steps:
24 | - name: Checkout
25 | uses: actions/checkout@v4
26 |
27 | - name: Setup Node.js
28 | uses: actions/setup-node@v3
29 | with:
30 | node-version: '18'
31 |
32 | - name: Install dependencies
33 | run: npm install
34 |
35 | - name: Build project
36 | run: npm run build
37 | env:
38 | VITE_DEPLOY_TO_ASTRIA: true
39 |
40 | - name: Setup Pages
41 | uses: actions/configure-pages@v5
42 |
43 | - name: Upload artifact
44 | uses: actions/upload-pages-artifact@v3
45 | with:
46 | path: 'dist'
47 |
48 | - name: Deploy to GitHub Pages
49 | id: deployment
50 | uses: actions/deploy-pages@v4
51 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ))
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
29 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SliderPrimitive from "@radix-ui/react-slider"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Slider = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
19 |
20 |
21 |
22 |
23 | ))
24 | Slider.displayName = SliderPrimitive.Root.displayName
25 |
26 | export { Slider }
27 |
--------------------------------------------------------------------------------
/src/hooks/useAPrompt.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Prompt } from "@/types";
3 | import { usePromptFormStore } from "@/store/promptFormStore";
4 |
5 | export const useAPrompt = () => {
6 | const [isPromptUsed, setIsPromptUsed] = useState(false);
7 |
8 | const { setHeight, setWidth, setPromptText, setControlNet, setColorGrading, setSuperResolution, setHiresFix, setInpaintFaces, setFaceCorrect, setFaceSwap, setDenoisingStrength, setConditioningScale, setNumImages, setUrlImage } = usePromptFormStore();
9 |
10 | const handleUsePrompt = async (prompt: Prompt) => {
11 | setHeight(prompt.h);
12 | setWidth(prompt.w);
13 | setPromptText(prompt.text);
14 | setControlNet(prompt.controlnet);
15 | setColorGrading(prompt.color_grading);
16 | setSuperResolution(prompt.super_resolution);
17 | setHiresFix(prompt.hires_fix);
18 | setInpaintFaces(prompt.inpaint_faces);
19 | setFaceCorrect(prompt.face_correct);
20 | setFaceSwap(prompt.face_swap);
21 | setDenoisingStrength(prompt.denoising_strength);
22 | setConditioningScale(prompt.controlnet_conditioning_scale);
23 | setNumImages(prompt.num_images);
24 | setUrlImage(prompt.input_image);
25 | setIsPromptUsed(true);
26 | setTimeout(() => {
27 | setIsPromptUsed(false);
28 | }, 2000);
29 | };
30 |
31 | return { isPromptUsed, handleUsePrompt };
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/MoonToggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react"
2 |
3 | import { Button } from "@/components/ui/button"
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuTrigger,
9 | } from "@/components/ui/dropdown-menu"
10 | import { useTheme } from "@/components/theme-provider"
11 |
12 | export default function ModeToggle() {
13 | const { setTheme } = useTheme()
14 |
15 | return (
16 |
17 |
18 |
23 |
24 |
25 | setTheme("light")}>
26 | Light
27 |
28 | setTheme("dark")}>
29 | Dark
30 |
31 | setTheme("system")}>
32 | System
33 |
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/MobileTabNav.tsx:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom';
2 | import { PhotoIcon, HomeIcon } from '@heroicons/react/24/outline';
3 |
4 | const tabs = [
5 | { name: 'My Prompts', path: '/', icon: HomeIcon },
6 | { name: 'Gallery', path: '/gallery', icon: PhotoIcon },
7 | ];
8 |
9 | function MobileTabNav() {
10 | return (
11 |
29 | );
30 | }
31 |
32 | export default MobileTabNav;
33 |
--------------------------------------------------------------------------------
/src/components/PromptImage.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { X } from "lucide-react";
3 | import { useNavigate } from "react-router-dom";
4 |
5 | interface PromptImageProps {
6 | imageUrl: string;
7 | setDisplay?: React.Dispatch>;
8 | }
9 |
10 | const PromptImage: React.FC = ({ imageUrl, setDisplay }) => {
11 | const navigate = useNavigate();
12 |
13 | return (
14 | setDisplay && setDisplay(true)}
17 | >
18 |
19 |

{
24 | e.currentTarget.onerror = null;
25 | e.currentTarget.src =
26 | "https://www.astria.ai/assets/logo-b4e21f646fb5879eb91113a70eae015a7413de8920960799acb72c60ad4eaa99.png";
27 | }}
28 | />
29 |
30 |
39 |
40 | );
41 | };
42 |
43 | export default PromptImage;
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from "path"
2 | import { defineConfig } from 'vite'
3 | import react from '@vitejs/plugin-react'
4 | import dotenv from 'dotenv'
5 |
6 | // Load environment variables from .env file
7 | dotenv.config();
8 |
9 | const BEARER_TOKEN = process.env.ASTRIA_API_KEY;
10 |
11 | // https://vitejs.dev/config/
12 | export default defineConfig({
13 | plugins: [react()],
14 | // Set base URL to "/imagine/" by default, or "" if explicitly set to "false"
15 | base: process.env.VITE_DEPLOY_TO_ASTRIA === "true" ? "/imagine/" : "",
16 | resolve: {
17 | alias: {
18 | "@": path.resolve(__dirname, "./src"),
19 | },
20 | },
21 | server: {
22 | port: 5173,
23 | ...(BEARER_TOKEN && {
24 | proxy: {
25 | '/api': {
26 | target: 'https://api.astria.ai',
27 | changeOrigin: true,
28 | rewrite: (path) => path.replace(/^\/api/, ''),
29 | configure: (proxy) => {
30 | proxy.on('proxyReq', (proxyReq) => {
31 | proxyReq.setHeader('Authorization', `Bearer ${BEARER_TOKEN}`);
32 | });
33 | }
34 | },
35 | '/rails/active_storage/blobs': {
36 | target: 'https://api.astria.ai',
37 | changeOrigin: true,
38 | configure: (proxy) => {
39 | proxy.on('proxyReq', (proxyReq) => {
40 | proxyReq.setHeader('Authorization', `Bearer ${BEARER_TOKEN}`);
41 | });
42 | }
43 | }
44 | }
45 | })
46 | }
47 | })
48 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border border-zinc-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:ring-offset-2 dark:border-zinc-800 dark:focus:ring-zinc-300",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/80 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/80",
13 | secondary:
14 | "border-transparent bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
15 | destructive:
16 | "border-transparent bg-red-500 text-zinc-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/80",
17 | outline: "text-zinc-950 dark:text-zinc-50",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SwitchPrimitives from "@radix-ui/react-switch";
3 | import { cn } from "@/lib/utils";
4 |
5 | const Switch = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
17 |
23 | {/* Replace this with your desired icon */}
24 |
25 | {props.checked ? "On" : "Off"}
26 |
27 |
28 |
29 | ));
30 |
31 | Switch.displayName = SwitchPrimitives.Root.displayName;
32 |
33 | export { Switch };
34 |
--------------------------------------------------------------------------------
/src/api/prompts.ts:
--------------------------------------------------------------------------------
1 | import { apiClient, apiClient2 } from "@/services/apiClient";
2 | import { Prompt } from "@/types";
3 |
4 | export const fetchGalleryPrompts = async (offset: number, limit: number): Promise => {
5 | const response = await apiClient.get(`/gallery.json?offset=${offset}&limit=${limit}`);
6 | return response.data || [];
7 | };
8 |
9 | export const fetchUserPrompts = async (offset: number, limit: number): Promise => {
10 | const response = await apiClient.get(`/prompts?offset=${offset}&limit=${limit}`);
11 | return response.data || [];
12 | };
13 |
14 | export const createPrompt = async (formData: FormData): Promise => {
15 | const response = await apiClient2.post(`/prompts`, formData);
16 | return response.data;
17 | };
18 |
19 | export const retrievePrompt = async (tuneId: number, promptId: number): Promise => {
20 | const response = await apiClient.get(`/tunes/${tuneId}/prompts/${promptId}`);
21 | return response.data;
22 | }
23 |
24 | export const likePrompt = async (promptId: number) => {
25 | const response = await apiClient.post(`/prompts/${promptId}/like`);
26 | // console.log(response);
27 | if (response.status === 200 ){
28 | return true;
29 | }
30 | };
31 |
32 | export const getTunes = async (
33 | page: number,
34 | limit: number,
35 | searchQuery: string
36 | ) => {
37 | const response = await apiClient.get(`/tunes?branch=flux1&model_type=lora&page=${page}&limit=${limit}&title=${searchQuery}`);
38 | return response.data;
39 | };
40 |
41 | export const trashPrompt = async (tuneId:number, promptId: number) => {
42 | const response = await apiClient.delete(`/tunes/${tuneId}/prompts/${promptId}`);
43 | if (response.status === 204) {
44 | return response.status;
45 | }
46 | };
--------------------------------------------------------------------------------
/src/hooks/usePromptPolling.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Prompt } from "@/types";
3 |
4 | // Custom hook for handling prompt polling
5 | const usePromptPolling = (
6 | initialPrompt: Prompt,
7 | retrieveSinglePrompt: (tuneId: number, promptId: number) => Promise,
8 | updateSinglePrompt: (tuneId: number, promptId: number) => void
9 | ) => {
10 | const [retrievedPrompt, setRetrievedPrompt] = useState(null);
11 |
12 | useEffect(() => {
13 | let pollingInterval: NodeJS.Timeout | null = null;
14 |
15 | const pollPrompt = async () => {
16 | try {
17 | const result = await retrieveSinglePrompt(initialPrompt.tune_id, initialPrompt.id);
18 | setRetrievedPrompt(result);
19 |
20 | // If the retrieved prompt has images, stop polling and update
21 | if (result.images?.length > 0) {
22 | if (pollingInterval) {
23 | clearInterval(pollingInterval);
24 | }
25 | updateSinglePrompt(initialPrompt.tune_id, initialPrompt.id);
26 | }
27 | } catch (error) {
28 | console.error('Error polling prompt:', error);
29 | }
30 | };
31 |
32 | // Only start polling if initial prompt has no images
33 | if (!initialPrompt.images?.length) {
34 | // Initial check
35 | pollPrompt();
36 | // Start polling every 10 seconds
37 | pollingInterval = setInterval(pollPrompt, 10000);
38 | }
39 |
40 | return () => {
41 | if (pollingInterval) {
42 | clearInterval(pollingInterval);
43 | }
44 | };
45 | }, [initialPrompt.id, initialPrompt.tune_id, initialPrompt.images, retrieveSinglePrompt, updateSinglePrompt]);
46 |
47 | return retrievedPrompt;
48 | };
49 |
50 |
51 | export default usePromptPolling;
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | font-family: -apple-system, blinkmacsystemfont, san francisco, roboto, segoe ui, helvetica neue, sans-serif;
7 | line-height: 1.5;
8 | font-weight: 400;
9 |
10 | color-scheme: light dark;
11 | color: rgba(255, 255, 255, 0.87);
12 | background-color: #242424;
13 |
14 | font-synthesis: none;
15 | text-rendering: optimizeLegibility;
16 | -webkit-font-smoothing: antialiased;
17 | -moz-osx-font-smoothing: grayscale;
18 | }
19 |
20 | body {
21 | margin: 0;
22 | min-width: 320px;
23 | min-height: 100vh;
24 | background-color: hsl(225 10% 99%/var(--tw-bg-opacity));
25 | }
26 |
27 | @layer base {
28 | :root {
29 | --radius: 0.5rem;
30 | }
31 | }
32 |
33 |
34 | .bg-light-mode {
35 | --tw-bg-opacity: 1;
36 | background-color: hsl(225 10% 99%/var(--tw-bg-opacity));
37 | }
38 |
39 | .text-primary {
40 | --tw-text-opacity: 1;
41 | color: hsl(225 10% 20%/var(--tw-text-opacity));
42 | }
43 |
44 | @layer utilities {
45 | .scrollbar::-webkit-scrollbar {
46 | width: 2px;
47 | height: 20px;
48 | }
49 |
50 | .scrollbar::-webkit-scrollbar-track {
51 | border-radius: 100vh;
52 | background: #fdfdfd;
53 | }
54 |
55 | .scrollbar::-webkit-scrollbar-thumb {
56 | background: #e7a200;
57 | border-radius: 100vh;
58 | border: 3px solid #ffc956;
59 | }
60 |
61 | .scrollbar::-webkit-scrollbar-thumb:hover {
62 | background: #ffa600;
63 | }
64 | }
65 |
66 | input[type='range']::-webkit-slider-thumb {
67 | -webkit-appearance: none;
68 | appearance: none;
69 | width: 16px;
70 | height: 16px;
71 | border-radius: 50%;
72 | background: #4a5568;
73 | cursor: pointer;
74 | }
75 | input[type='range']::-moz-range-thumb {
76 | width: 16px;
77 | height: 16px;
78 | border-radius: 50%;
79 | background: #4a5568;
80 | cursor: pointer;
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react"
2 |
3 | type Theme = "dark" | "light" | "system"
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode
7 | defaultTheme?: Theme
8 | storageKey?: string
9 | }
10 |
11 | type ThemeProviderState = {
12 | theme: Theme
13 | setTheme: (theme: Theme) => void
14 | }
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "system",
18 | setTheme: () => null,
19 | }
20 |
21 | const ThemeProviderContext = createContext(initialState)
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = "system",
26 | storageKey = "vite-ui-theme",
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(
30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
31 | )
32 |
33 | useEffect(() => {
34 | const root = window.document.documentElement
35 |
36 | root.classList.remove("light", "dark")
37 |
38 | if (theme === "system") {
39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
40 | .matches
41 | ? "dark"
42 | : "light"
43 |
44 | root.classList.add(systemTheme)
45 | return
46 | }
47 |
48 | root.classList.add(theme)
49 | }, [theme])
50 |
51 | const value = {
52 | theme,
53 | setTheme: (theme: Theme) => {
54 | localStorage.setItem(storageKey, theme)
55 | setTheme(theme)
56 | },
57 | }
58 |
59 | return (
60 |
61 | {children}
62 |
63 | )
64 | }
65 |
66 | export const useTheme = () => {
67 | const context = useContext(ThemeProviderContext)
68 |
69 | if (context === undefined)
70 | throw new Error("useTheme must be used within a ThemeProvider")
71 |
72 | return context
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/Gallery.tsx:
--------------------------------------------------------------------------------
1 | // components/MasonryGallery.tsx
2 | import React, { useState } from "react";
3 | import InfiniteScroll from "react-infinite-scroll-component";
4 | import LoadingSpinner from "@/components/LoadingSpinner";
5 | import MasonryLayout from "@/components/MasonryLayout";
6 | import GalleryItem from "@/components/GalleryItem";
7 | import { Prompt } from "@/types";
8 |
9 | interface MasonryGalleryProps {
10 | prompts: Prompt[];
11 | fetchMoreData: () => Promise;
12 | }
13 |
14 | const Gallery: React.FC = ({
15 | prompts,
16 | fetchMoreData,
17 | }) => {
18 | const [hasMore, setHasMore] = useState(true);
19 | const [isLoading, setIsLoading] = useState(false);
20 |
21 |
22 | const loadMorePrompts = async () => {
23 | if (isLoading) return; // Prevent multiple fetches
24 | setIsLoading(true);
25 | try {
26 | const hasMoreData = await fetchMoreData();
27 | setHasMore(hasMoreData);
28 | } catch (error) {
29 | console.error("Failed to load more data", error);
30 | setHasMore(false); // Stop loading on error
31 | } finally {
32 | setIsLoading(false);
33 | }
34 | };
35 |
36 | return (
37 |
38 |
}
43 | endMessage={
44 |
45 | Yay! You have seen it all
46 |
47 | }
48 | scrollThreshold={0.8}
49 | style={{ overflow: "hidden" }}
50 | >
51 |
52 | {prompts.map((prompt, index) => (
53 |
54 | ))}
55 |
56 |
57 |
58 | );
59 | };
60 |
61 | export default Gallery;
62 |
63 |
--------------------------------------------------------------------------------
/src/components/PromptForm/PromptControls.tsx:
--------------------------------------------------------------------------------
1 | import { AdjustmentsHorizontalIcon, PhotoIcon } from "@heroicons/react/24/outline";
2 | import { Button } from "@/components/ui/button";
3 | import { SendHorizontal } from "lucide-react"
4 | import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
5 |
6 | interface PromptControlsProps {
7 | onImageClick: () => void;
8 | onAdvancedClick: () => void;
9 | onSubmit: () => void;
10 | isLoading: boolean;
11 | }
12 |
13 | export const PromptControls: React.FC = ({
14 | onImageClick,
15 | onAdvancedClick,
16 | onSubmit,
17 | isLoading,
18 | }) => (
19 | <>
20 |
21 |
22 |
23 |
26 |
27 | Upload images
28 |
29 |
30 |
31 |
32 |
33 |
34 |
37 |
38 | Advanced settings
39 |
40 |
41 |
42 |
50 | >
51 | );
--------------------------------------------------------------------------------
/src/components/PromptCard/PromptCard.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useNavigate } from "react-router-dom";
3 | import { useStore } from "@/store/promptStore";
4 | import { Prompt } from "@/types";
5 | import { ImageGrid } from "./ImageGrid";
6 | import { PropertiesDisplay } from "./PropertiesDisplay";
7 | import { ActionButtons } from "./ActionButtons";
8 | import usePromptPolling from "@/hooks/usePromptPolling";
9 |
10 | interface PromptCardProps {
11 | prompt: Prompt;
12 | promptIndex: number;
13 | }
14 |
15 | const PromptCard: React.FC = ({ prompt: initialPrompt, promptIndex }) => {
16 | const navigate = useNavigate();
17 | const { retrieveSinglePrompt, updateSinglePrompt } = useStore();
18 | const retrievedPrompt = usePromptPolling(initialPrompt, retrieveSinglePrompt, updateSinglePrompt);
19 | const displayPrompt = retrievedPrompt || initialPrompt;
20 |
21 | const handleImageClick = (index: number) => {
22 | navigate(`/prompt/${displayPrompt.id}/${index}`, { state: { type: "user" } });
23 | };
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
31 | {displayPrompt.text?.length > 90 ? `${displayPrompt.text.slice(0, 90)}...` : displayPrompt.text}
32 |
33 |
34 |
35 |
36 |
{promptIndex}
37 |
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export default PromptCard;
45 |
--------------------------------------------------------------------------------
/src/components/PromptForm/ControlNetSelector.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToggleGroup from "@radix-ui/react-toggle-group";
3 | import { cn } from "@/lib/utils";
4 |
5 | const controlNetOptions = [
6 | { label: 'Canny', value: 'canny' },
7 | { label: 'Depth', value: 'depth' },
8 | { label: 'Pose', value: 'pose' },
9 | ];
10 |
11 | interface ControlNetSelectorProps {
12 | value?: string;
13 | onChange?: (model: string) => void;
14 | className?: string;
15 | error?: string;
16 | }
17 |
18 | const ControlNetSelector: React.FC = ({
19 | value = '',
20 | onChange,
21 | className = '',
22 | error
23 | }) => {
24 | const handleValueChange = (model: string) => {
25 | onChange?.(model);
26 | };
27 |
28 | return (
29 |
30 |
31 |
34 |
35 |
41 | {controlNetOptions.map((option, index) => (
42 |
51 | {option.label}
52 |
53 | ))}
54 |
55 |
56 |
57 | {error &&
{error}
}
58 |
59 | );
60 | };
61 |
62 | export default ControlNetSelector;
63 |
--------------------------------------------------------------------------------
/src/services/apiClient.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios';
2 |
3 | // Conditionally set BASE_URL based on VITE_PROXY_URL environment variable
4 | const BASE_URL = import.meta.env.VITE_DEPLOY_TO_ASTRIA === 'true' ? '' : '/api';
5 |
6 | const apiClient: AxiosInstance = axios.create({
7 | baseURL: BASE_URL,
8 | headers: {
9 | 'Content-Type': 'application/json',
10 | Accept: 'application/json',
11 | "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
12 | },
13 | });
14 |
15 | // multipart form data api client2
16 | const apiClient2: AxiosInstance = axios.create({
17 | baseURL: BASE_URL,
18 | headers: {
19 | 'Content-Type': 'multipart/form-data',
20 | Accept: 'application/json',
21 | "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
22 | },
23 | });
24 |
25 | interface ApiResponse {
26 | data: T;
27 | status: number;
28 | statusText: string;
29 | }
30 |
31 | export const get = async (url: string, params?: object): Promise> => {
32 | try {
33 | const response: AxiosResponse = await apiClient.get(url, { params });
34 | return {
35 | data: response.data,
36 | status: response.status,
37 | statusText: response.statusText,
38 | };
39 | } catch (error) {
40 | handleApiError(error as AxiosError);
41 | throw error;
42 | }
43 | };
44 |
45 | export const post = async (url: string, data: object): Promise> => {
46 | try {
47 | const response: AxiosResponse = await apiClient.post(url, data);
48 | return {
49 | data: response.data,
50 | status: response.status,
51 | statusText: response.statusText,
52 | };
53 | } catch (error) {
54 | handleApiError(error as AxiosError);
55 | throw error;
56 | }
57 | };
58 |
59 | const handleApiError = (error: AxiosError): void => {
60 | if (error.response?.status === 401) {
61 | window.location.href = 'https://www.astria.ai/users/sign_in';
62 | return;
63 | }
64 | };
65 |
66 | export { apiClient, apiClient2 };
67 |
--------------------------------------------------------------------------------
/src/components/PromptForm/ColorGradingSelector.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToggleGroup from "@radix-ui/react-toggle-group";
3 | import { cn } from "@/lib/utils";
4 |
5 | const colorGradingOptions = [
6 | { label: 'Film Velvia', value: 'film-velvia' },
7 | { label: 'Film Portra', value: 'film-portra' },
8 | { label: 'Ektar', value: 'ektar' },
9 | ];
10 |
11 | interface ColorGradingSelectorProps {
12 | value?: string;
13 | onChange?: (grading: string) => void;
14 | className?: string;
15 | error?: string;
16 | }
17 |
18 | const ColorGradingSelector: React.FC = ({
19 | value = 'film-velvia',
20 | onChange,
21 | className = '',
22 | error,
23 | }) => {
24 | const handleValueChange = (grading: string) => {
25 | onChange?.(grading);
26 | };
27 |
28 |
29 | return (
30 |
31 |
32 |
35 |
36 |
42 | {colorGradingOptions.map((option, index) => (
43 |
53 | {option.label}
54 |
55 | ))}
56 |
57 |
58 |
59 | {error &&
{error}
}
60 |
61 | );
62 | };
63 |
64 | export default ColorGradingSelector;
65 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/src/store/promptFormStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { PromptFormState } from '@/types';
3 |
4 | export const usePromptFormStore = create((set) => ({
5 | // Text and dimensions
6 | promptText: '',
7 | setPromptText: (promptText) => set({ promptText }),
8 | width: 1024,
9 | setWidth: (width) => set({ width }),
10 | height: 1024,
11 | setHeight: (height) => set({ height }),
12 |
13 | // Image state
14 | image: null,
15 | setImage: (image) => set({ image }),
16 | urlImage: null,
17 | setUrlImage: (urlImage) => set({ urlImage }),
18 |
19 | // Control settings
20 | controlNet: '',
21 | setControlNet: (controlNet) => set({ controlNet }),
22 | colorGrading: '',
23 | setColorGrading: (colorGrading) => set({ colorGrading }),
24 | filmGrain: false,
25 | setFilmGrain: (filmGrain) => set({ filmGrain }),
26 | controlNetTXT2IMG: false,
27 | setControlNetTXT2IMG: (controlNetTXT2IMG) => set({ controlNetTXT2IMG }),
28 |
29 | // Image enhancement settings
30 | superResolution: false,
31 | setSuperResolution: (superResolution) => set({ superResolution }),
32 | hiresFix: false,
33 | setHiresFix: (hiresFix) => set({ hiresFix }),
34 | inpaintFaces: false,
35 | setInpaintFaces: (inpaintFaces) => set({ inpaintFaces }),
36 | faceCorrect: false,
37 | setFaceCorrect: (faceCorrect) => set({ faceCorrect }),
38 | faceSwap: false,
39 | setFaceSwap: (faceSwap) => set({ faceSwap }),
40 |
41 | // Advanced settings
42 | backendVersion: '0',
43 | setBackendVersion: (backendVersion) => set({ backendVersion }),
44 | denoisingStrength: 0.8,
45 | setDenoisingStrength: (denoisingStrength) => set({ denoisingStrength }),
46 | conditioningScale: 0.8,
47 | setConditioningScale: (conditioningScale) => set({ conditioningScale }),
48 | numImages: 4,
49 | setNumImages: (numImages) => set({ numImages }),
50 | steps: null,
51 | setSteps: (steps) => set({ steps }),
52 | seed: null,
53 | setSeed: (seed) => set({ seed }),
54 |
55 | // Additional features
56 | loraTextList: [],
57 | setLoraTextList: (loraTextList) => set({ loraTextList }),
58 |
59 | // Status and error handling
60 | error: {},
61 | setError: (error) => set({ error }),
62 | isLoading: false,
63 | setIsLoading: (isLoading) => set({ isLoading }),
64 | }));
65 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TabsPrimitive from "@radix-ui/react-tabs"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/src/components/PromptForm/ImageUpload.tsx:
--------------------------------------------------------------------------------
1 | import { XMarkIcon } from "@heroicons/react/24/outline";
2 | import { useImageUpload } from "@/hooks/useImageUpload";
3 | import { usePromptFormStore } from "@/store/promptFormStore";
4 | import { useMemo } from "react";
5 |
6 | interface ImageUploadProps {
7 | showControls: boolean;
8 | onImageUpload: (image: File | null, urlImage: string | null) => void;
9 | }
10 |
11 | export const ImageUpload: React.FC = ({ showControls, onImageUpload }) => {
12 | const {
13 | uploadError,
14 | getRootProps,
15 | getInputProps,
16 | clearImage,
17 | isDragActive,
18 | } = useImageUpload();
19 |
20 | const { image, urlImage } = usePromptFormStore();
21 | // console.log({ image, urlImage });
22 | const handleClear = () => {
23 | clearImage();
24 | onImageUpload(null, null);
25 | };
26 |
27 | const imageSrc = useMemo(() => {
28 | if (image) {
29 | return URL.createObjectURL(image);
30 | }
31 | return urlImage;
32 | }, [image, urlImage]);
33 |
34 | if (!showControls && !image && !urlImage) return null;
35 |
36 | return (
37 |
38 |
43 |
44 | {image || urlImage ? (
45 |
46 |

51 |
{
52 | e.stopPropagation();
53 | handleClear();
54 | }} className="h-4 w-4 absolute top-1 right-1 hidden group-hover:block rounded-sm text-black group-hover:bg-gray-300" />
55 |
56 | ) : (
57 |
58 | {isDragActive ? "Drop image here" : "Select image to use"}
59 |
60 | )}
61 |
62 | {uploadError &&
{uploadError}
}
63 |
64 | );
65 | };
--------------------------------------------------------------------------------
/src/components/PromptCard/ActionButtons.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button } from "../ui/button";
3 | import { Copy, Heart, Trash, Check, Type } from "lucide-react";
4 | import { Prompt } from "@/types";
5 | import { useCopy } from "@/hooks/useCopy";
6 | import { useLike } from "@/hooks/useLike";
7 | import { useTrash } from "@/hooks/useTrash";
8 | import { useAPrompt } from "@/hooks/useAPrompt";
9 |
10 | interface ActionButtonsProps {
11 | prompt: Prompt;
12 | }
13 |
14 | export const ActionButtons: React.FC = ({ prompt }) => {
15 | const { isCopied, handleCopy } = useCopy();
16 | const { isLiked, handleLike } = useLike(prompt?.liked);
17 | const { isTrashed, handleTrash } = useTrash();
18 |
19 | const { isPromptUsed, handleUsePrompt } = useAPrompt();
20 |
21 | return (
22 |
23 |
32 |
41 |
50 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-zinc-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
14 | destructive:
15 | "bg-red-500 text-zinc-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-zinc-200 bg-white shadow-sm hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
18 | secondary:
19 | "bg-zinc-100 text-zinc-900 shadow-sm hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
20 | ghost: "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
21 | link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/tsconfig.app.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"root":["./src/main.tsx","./src/test.ts","./src/vite-env.d.ts","./src/api/prompts.ts","./src/components/Gallery.tsx","./src/components/GalleryItem.tsx","./src/components/ImageZoom.tsx","./src/components/LoadingSpinner.tsx","./src/components/MasonryLayout.tsx","./src/components/MobileTabNav.tsx","./src/components/ModelSelector.tsx","./src/components/MoonToggle.tsx","./src/components/Navbar.tsx","./src/components/PromptDetails.tsx","./src/components/PromptImage.tsx","./src/components/WithLayout.tsx","./src/components/theme-provider.tsx","./src/components/PromptCard/ActionButtons.tsx","./src/components/PromptCard/ImageGrid.tsx","./src/components/PromptCard/PromptCard.tsx","./src/components/PromptCard/PropertiesDisplay.tsx","./src/components/PromptCard/index.ts","./src/components/PromptForm/AddLoraText.tsx","./src/components/PromptForm/AdvancedControls.tsx","./src/components/PromptForm/AspectRatioSlider.tsx","./src/components/PromptForm/ColorGradingSelector.tsx","./src/components/PromptForm/ControlNetSelector.tsx","./src/components/PromptForm/ImageUpload.tsx","./src/components/PromptForm/PromptControls.tsx","./src/components/PromptForm/PromptForm.tsx","./src/components/PromptForm/index.ts","./src/components/ui/badge.tsx","./src/components/ui/bidirectional-slider.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/sheet.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-toast.ts","./src/hooks/useAPrompt.ts","./src/hooks/useCopy.ts","./src/hooks/useImageUpload.ts","./src/hooks/useInfiniteScroll.ts","./src/hooks/useLike.ts","./src/hooks/usePromptForm.ts","./src/hooks/usePromptNavigation.ts","./src/hooks/usePromptPolling.ts","./src/hooks/usePromptSubmit.ts","./src/hooks/usePrompts.ts","./src/hooks/useTrash.ts","./src/lib/utils.ts","./src/pages/Gallery.tsx","./src/pages/Prompt.tsx","./src/pages/UsersPrompts.tsx","./src/pages/index.ts","./src/services/apiClient.ts","./src/store/promptFormStore.ts","./src/store/promptStore.ts","./src/types/index.ts"],"version":"5.6.3"}
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "imagine",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "lint:fix": "eslint --fix .",
11 | "preview": "vite preview",
12 | "predeploy": "npm run build",
13 | "deploy": "gh-pages -d dist -r https://x-access-token:${GITHUB_TOKEN}@github.com/astriaai/imagine.git"
14 | },
15 | "dependencies": {
16 | "@heroicons/react": "^2.1.5",
17 | "@radix-ui/react-dialog": "^1.1.2",
18 | "@radix-ui/react-dropdown-menu": "^2.1.2",
19 | "@radix-ui/react-icons": "^1.3.0",
20 | "@radix-ui/react-label": "^2.1.0",
21 | "@radix-ui/react-select": "^2.1.2",
22 | "@radix-ui/react-slider": "^1.2.1",
23 | "@radix-ui/react-slot": "^1.1.0",
24 | "@radix-ui/react-switch": "^1.1.1",
25 | "@radix-ui/react-tabs": "^1.1.1",
26 | "@radix-ui/react-toast": "^1.2.2",
27 | "@radix-ui/react-toggle-group": "^1.1.0",
28 | "@radix-ui/react-tooltip": "^1.1.3",
29 | "@vitejs/plugin-react": "^4.3.3",
30 | "axios": "^1.7.7",
31 | "class-variance-authority": "^0.7.0",
32 | "clsx": "^2.1.1",
33 | "date-fns": "^4.1.0",
34 | "dotenv": "^16.4.5",
35 | "file-saver": "^2.0.5",
36 | "lucide-react": "^0.452.0",
37 | "react": "^18.3.1",
38 | "react-dom": "^18.3.1",
39 | "react-dropzone": "^14.2.10",
40 | "react-infinite-scroll-component": "^6.1.0",
41 | "react-masonry-css": "^1.0.16",
42 | "react-router-dom": "^6.27.0",
43 | "react-slick": "^0.30.2",
44 | "react-toastify": "^10.0.6",
45 | "slick-carousel": "^1.8.1",
46 | "tailwind-merge": "^2.5.3",
47 | "tailwindcss-animate": "^1.0.7",
48 | "zustand": "^5.0.0"
49 | },
50 | "devDependencies": {
51 | "@eslint/js": "^9.11.1",
52 | "@types/file-saver": "^2.0.7",
53 | "@types/node": "^22.7.5",
54 | "@types/react": "^18.3.10",
55 | "@types/react-dom": "^18.3.0",
56 | "@types/react-slick": "^0.23.13",
57 | "@vitejs/plugin-react-swc": "^3.5.0",
58 | "autoprefixer": "^10.4.20",
59 | "eslint": "^9.11.1",
60 | "eslint-plugin-react-hooks": "^5.1.0-rc.0",
61 | "eslint-plugin-react-refresh": "^0.4.12",
62 | "gh-pages": "^6.2.0",
63 | "globals": "^15.9.0",
64 | "postcss": "^8.4.47",
65 | "tailwindcss": "^3.4.13",
66 | "ts-node": "^10.9.2",
67 | "typescript": "^5.6.3",
68 | "typescript-eslint": "^8.7.0",
69 | "vite": "^5.4.8"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/hooks/useImageUpload.ts:
--------------------------------------------------------------------------------
1 | import { useDropzone } from 'react-dropzone';
2 | import { useState } from 'react';
3 | import { usePromptFormStore } from '@/store/promptFormStore';
4 | import { DropzoneRootProps, DropzoneInputProps } from 'react-dropzone';
5 |
6 | interface UseImageUploadReturn {
7 | uploadError: string | null;
8 | getRootProps: (props?: T) => T;
9 | getInputProps: (props?: T) => T;
10 | setImage: (file: File | null) => void;
11 | handleImagePaste: (event: ClipboardEvent) => void;
12 | handleUrlUpload: (url: string) => Promise;
13 | clearImage: () => void;
14 | isDragActive: boolean;
15 | }
16 |
17 | export const useImageUpload = (): UseImageUploadReturn => {
18 | const { setImage, setUrlImage } = usePromptFormStore();
19 | const [uploadError, setUploadError] = useState(null);
20 |
21 | const onDrop = (acceptedFiles: File[]) => {
22 | const isValid = acceptedFiles.every(file => file.type.startsWith('image/'));
23 | if (isValid) {
24 | setUploadError(null);
25 | setImage(acceptedFiles[0]);
26 | setUrlImage(null);
27 | } else {
28 | setUploadError('Only image files are accepted.');
29 | }
30 | };
31 |
32 | const { getRootProps, getInputProps, isDragActive } = useDropzone({
33 | onDrop,
34 | accept: { 'image/*': [] },
35 | multiple: false,
36 | });
37 |
38 | const handleImagePaste = (event: ClipboardEvent) => {
39 | const items = event.clipboardData?.items;
40 | if (items) {
41 | const imageItem = Array.from(items).find(item => item.type.startsWith('image/'));
42 | if (imageItem) {
43 | const file = imageItem.getAsFile();
44 | if (file) {
45 | setUploadError(null);
46 | setImage(file);
47 | setUrlImage(null);
48 | }
49 | } else {
50 | setUploadError('Only image files are accepted from clipboard.');
51 | }
52 | }
53 | };
54 |
55 | const handleUrlUpload = async (url: string) => {
56 | setImage(null);
57 | setUrlImage(url);
58 | setUploadError(null);
59 | };
60 |
61 | const clearImage = () => {
62 | setImage(null);
63 | setUrlImage(null);
64 | };
65 |
66 | return {
67 | uploadError,
68 | handleImagePaste,
69 | getRootProps,
70 | getInputProps,
71 | setImage,
72 | handleUrlUpload,
73 | clearImage,
74 | isDragActive,
75 | };
76 | };
77 |
--------------------------------------------------------------------------------
/src/components/PromptCard/ImageGrid.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Skeleton } from "../ui/skeleton";
3 |
4 | interface ImageGridProps {
5 | images?: string[];
6 | onClick: (index: number) => void;
7 | }
8 |
9 | export const ImageGrid: React.FC = ({
10 | images = [],
11 | onClick,
12 | }) => {
13 | const [loadedImages, setLoadedImages] = useState(
14 | Array(images.length).fill(false)
15 | );
16 |
17 | const handleImageLoad = (index: number) => {
18 | setLoadedImages((prev) => {
19 | const updated = [...prev];
20 | updated[index] = true;
21 | return updated;
22 | });
23 | };
24 |
25 | const getRoundedClass = (index: number, total: number) => {
26 | if (total === 1) return "rounded-md";
27 | if (total === 2) return index === 0 ? "rounded-l-md" : "rounded-r-md";
28 | if (total === 3) return ["rounded-tl-md", "rounded-tr-md", "rounded-b-md"][index];
29 | if (total === 4) return ["", "rounded-tr-md", "rounded-bl-md", "rounded-br-md"][index];
30 | if (total > 4) {
31 | if (index === 0) return "rounded-tl-md";
32 | if (index === 3) return "rounded-tr-md";
33 | if (index === total - 2) return "rounded-bl-md";
34 | if (index === total - 1) return "rounded-br-md";
35 | }
36 | return "";
37 | };
38 |
39 | if (!images.length) {
40 | return (
41 |
42 | {Array.from({ length: 4 }).map((_, i) => (
43 |
44 | ))}
45 |
46 | );
47 | }
48 |
49 | return (
50 |
51 | {images.map((image, i) => (
52 |
59 | {!loadedImages[i] ? (
60 | loadedImages.some((value) => value) ? (
61 |
62 | ) : (
63 |
64 | )
65 | ) : null}
66 |

handleImageLoad(i)}
72 | onError={() => console.warn(`Failed to load image at index ${i}`)}
73 | onClick={() => onClick(i)}
74 | />
75 |
76 | ))}
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/hooks/usePromptForm.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { usePromptFormStore } from "@/store/promptFormStore";
3 | import { useAPrompt } from './useAPrompt';
4 | import { Prompt } from '@/types';
5 |
6 | export const usePromptForm = () => {
7 | const [showAdvancedControls, setShowAdvancedControls] = useState(false);
8 | const [showImageControls, setShowImageControls] = useState(false);
9 | const { handleUsePrompt } = useAPrompt();
10 |
11 | const {
12 | promptText,
13 | setPromptText,
14 | isLoading,
15 | setImage: setStoreImage,
16 | setUrlImage: setStoreUrlImage,
17 | } = usePromptFormStore();
18 |
19 | const handleImageUpload = (newImage: File | null, newUrlImage: string | null) => {
20 | setStoreImage(newImage);
21 | setStoreUrlImage(newUrlImage);
22 | };
23 |
24 | const toggleImageControls = () => {
25 | setShowAdvancedControls(false);
26 | setShowImageControls(!showImageControls);
27 | };
28 |
29 | const toggleAdvancedControls = () => {
30 | setShowImageControls(false);
31 | setShowAdvancedControls(!showAdvancedControls);
32 | };
33 |
34 | const handlePaste = (event: ClipboardEvent) => {
35 | // console.log('handlePaste');
36 | //stop propagation to prevent the default paste behavior
37 | event.preventDefault();
38 | event.stopPropagation();
39 | const text = event.clipboardData?.getData('text');
40 |
41 | const allowedKeys = new Set([
42 | 'controlnet', 'color_grading', 'super_resolution', 'hires_fix', 'inpaint_faces',
43 | 'face_correct', 'face_swap', 'ar', 'denoising_strength', 'controlnet_conditioning_scale',
44 | 'num_images', 'w', 'h', 'input_image', 'controlnet_txt2img', 'film_grain', 'backend_version', 'seed', 'steps'
45 | ]);
46 |
47 | if (text) {
48 | const promptObj: Partial> = {};
49 | promptObj.text = text;
50 | text.split(' ').forEach(part => {
51 | const [key, value] = part.split('=');
52 | if (allowedKeys.has(key)) {
53 | const numValue = Number(value);
54 | if (key === 'super_resolution' || key === 'hires_fix' || key === 'inpaint_faces' || key === 'face_correct' || key === 'face_swap') {
55 | promptObj[key as keyof Prompt] = Boolean(numValue);
56 | } else {
57 | promptObj[key as keyof Prompt] = !isNaN(numValue) ? numValue : value;
58 | }
59 | }
60 | });
61 | handleUsePrompt(promptObj as unknown as Prompt);
62 | }
63 | };
64 |
65 |
66 | return {
67 | showAdvancedControls,
68 | showImageControls,
69 | promptText,
70 | isLoading,
71 | handlePaste,
72 | setPromptText,
73 | handleImageUpload,
74 | toggleImageControls,
75 | toggleAdvancedControls,
76 | setShowAdvancedControls,
77 | };
78 | };
--------------------------------------------------------------------------------
/src/components/GalleryItem.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Heart } from "lucide-react";
3 | import { Clipboard } from "lucide-react";
4 | import { useNavigate } from "react-router-dom";
5 | import { useLike } from "@/hooks/useLike";
6 | import { useCopy } from "@/hooks/useCopy";
7 | import { Prompt } from "@/types";
8 | import { Skeleton } from "./ui/skeleton";
9 |
10 | interface GalleryItemProps {
11 | prompt: Prompt;
12 | index: number;
13 | }
14 |
15 | const GalleryItem: React.FC = ({ prompt, index }) => {
16 | const navigate = useNavigate();
17 | const { isLiked: initialIsLiked, handleLike } = useLike(prompt.liked);
18 | const { isCopied, handleCopy } = useCopy();
19 | const [imageLoaded, setImageLoaded] = useState(false);
20 |
21 | // Local state to track like status and like count
22 | const [isLiked, setIsLiked] = useState(initialIsLiked);
23 | const [likeCount, setLikeCount] = useState(prompt.prompt_likes_count);
24 |
25 | const toggleLike = (e: React.MouseEvent) => {
26 | e.stopPropagation();
27 |
28 | // Update the like status and like count in the UI
29 | setIsLiked((prev) => !prev);
30 | setLikeCount((prev) => (isLiked ? prev - 1 : prev + 1));
31 |
32 | // Call the hook's handleLike function to handle backend update
33 | handleLike(prompt.id);
34 | };
35 |
36 | return (
37 |
41 | navigate(`/prompt/${prompt.id}/0`, { state: { type: "gallery" } })
42 | }
43 | >
44 |
45 | {!imageLoaded &&
}
46 |

setImageLoaded(true)}
51 | />
52 |
53 |
54 | {index + 1}
55 |
56 |
57 |
67 |
68 |
76 |
77 |
78 |
79 |
80 | );
81 | };
82 |
83 | export default GalleryItem;
84 |
--------------------------------------------------------------------------------
/src/hooks/usePromptSubmit.ts:
--------------------------------------------------------------------------------
1 | import { usePromptFormStore } from "@/store/promptFormStore";
2 | import { createPrompt } from "@/api/prompts";
3 | import { toast } from "react-toastify";
4 | import { useStore } from "@/store/promptStore";
5 |
6 |
7 | export const usePromptSubmit = () => {
8 | const {
9 | promptText,
10 | image,
11 | urlImage,
12 | controlNet,
13 | colorGrading,
14 | controlNetTXT2IMG,
15 | backendVersion,
16 | filmGrain,
17 | superResolution,
18 | hiresFix,
19 | inpaintFaces,
20 | faceCorrect,
21 | faceSwap,
22 | denoisingStrength,
23 | conditioningScale,
24 | numImages,
25 | seed,
26 | steps,
27 | width,
28 | height,
29 | setIsLoading,
30 | setError,
31 | } = usePromptFormStore();
32 |
33 | const { refreshUserPrompts } = useStore();
34 |
35 | const extractPromptText = (text: string) => {
36 | const allowedKeys = new Set([
37 | 'controlnet', 'color_grading', 'super_resolution', 'hires_fix', 'inpaint_faces',
38 | 'face_correct', 'face_swap', 'ar', 'denoising_strength', 'controlnet_conditioning_scale', 'controlnet_txt2img', 'num_images', 'w', 'h', 'input_image', 'film_grain', 'backend_version', 'seed', 'steps'
39 | ]);
40 | return text
41 | .split(' ')
42 | .filter(part => !allowedKeys.has(part.split('=')[0]))
43 | .join(' ');
44 | };
45 |
46 | const handleSubmit = async () => {
47 | if (!promptText.trim()) {
48 | toast.error("Please enter a prompt");
49 | return;
50 | }
51 |
52 | setIsLoading(true);
53 | try {
54 | const formData = new FormData();
55 | const promptData = {
56 | text: extractPromptText(promptText),
57 | tune_id: "1504944",
58 | input_image: image,
59 | input_image_url: urlImage,
60 | control_net: controlNet,
61 | color_grading: colorGrading,
62 | film_grain: filmGrain,
63 | super_resolution: superResolution,
64 | hires_fix: hiresFix,
65 | inpaint_faces: inpaintFaces,
66 | face_correct: faceCorrect,
67 | face_swap: faceSwap,
68 | denoising_strength: denoisingStrength,
69 | conditioning_scale: conditioningScale,
70 | controlnet_txt2img: controlNetTXT2IMG,
71 | num_images: numImages,
72 | w: width,
73 | h: height,
74 | backend_version: backendVersion,
75 | seed: seed,
76 | steps: steps
77 | };
78 |
79 | // Append each entry in `promptData` to formData
80 | Object.entries(promptData).forEach(([key, value]) => {
81 | if (value !== undefined && value !== null) {
82 | if (key === "input_image" && value instanceof File) {
83 | formData.append(`prompt[${key}]`, value);
84 | } else {
85 | formData.append(`prompt[${key}]`, String(value));
86 | }
87 | }
88 | });
89 |
90 | await createPrompt(formData);
91 | toast.success("Prompt created successfully!");
92 | refreshUserPrompts();
93 | return true;
94 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
95 | } catch (error: any) {
96 | console.error(error);
97 | setError(error.response?.data || "Error creating prompt");
98 | toast.error("Error creating prompt: " + (error.response?.data?.base.join(', ') || "Unknown error"));
99 | return false;
100 | } finally {
101 | setIsLoading(false);
102 | }
103 | };
104 |
105 | return { handleSubmit };
106 | };
107 |
--------------------------------------------------------------------------------
/src/store/promptStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { Prompt, PromptsState } from "@/types";
3 | import { fetchGalleryPrompts, fetchUserPrompts, retrievePrompt } from "@/api/prompts";
4 | import { usePromptFormStore } from "./promptFormStore";
5 |
6 | export const useStore = create((set) => ({
7 | galleryPrompts: [],
8 | userPrompts: [],
9 | galleryOffset: 0,
10 | userOffset: 0,
11 | limit: 20,
12 |
13 | addGalleryPrompts: (prompts) =>
14 | set((state) => ({
15 | galleryPrompts: [...state.galleryPrompts, ...prompts],
16 | galleryOffset: state.galleryOffset + prompts.length,
17 | })),
18 |
19 | addUserPrompts: (prompts) =>
20 | set((state) => {
21 | if (state.userPrompts.length === 0) {
22 | state.updatePromptForm(prompts[0]);
23 | }
24 | return ({
25 | userPrompts: [...state.userPrompts, ...prompts],
26 | userOffset: state.userOffset + prompts.length,
27 | })
28 | }),
29 |
30 | resetGalleryPrompts: () =>
31 | set({
32 | galleryPrompts: [],
33 | galleryOffset: 0,
34 | }),
35 |
36 | resetUserPrompts: () =>
37 | set({
38 | userPrompts: [],
39 | userOffset: 0,
40 | }),
41 |
42 | refreshGalleryPrompts: async () => {
43 | const prompts = await fetchGalleryPrompts(0, 20); // Fetch first set
44 | set({ galleryPrompts: prompts, galleryOffset: prompts.length }); // Update state with new prompts
45 | },
46 |
47 | refreshUserPrompts: async () => {
48 | const prompts = await fetchUserPrompts(0, 20); // Fetch first set
49 | set({ userPrompts: prompts, userOffset: prompts.length }); // Update state with new prompts
50 | },
51 |
52 | retrieveSinglePrompt: async (tuneId: number, promptId: number) => {
53 | const prompt = await retrievePrompt(tuneId, promptId);
54 | return prompt;
55 | },
56 | removeSinglePrompt: (promptId: number) => {
57 | set((state) => {
58 | const userPrompts = state.userPrompts.filter((p) => p.id !== promptId);
59 | return { userPrompts };
60 | });
61 | },
62 |
63 | updateSinglePrompt: async (tuneId: number, promptId: number) => {
64 | // Update the prompt in the store
65 | const prompt = await retrievePrompt(tuneId, promptId);
66 | set((state) => {
67 | const userPrompts = state.userPrompts.map((p) => {
68 | if (p.id === promptId) {
69 | return prompt;
70 | }
71 | return p;
72 | });
73 |
74 | return { userPrompts };
75 | });
76 | },
77 |
78 | updatePromptForm: (prompt: Prompt) => {
79 | if (prompt) {
80 | usePromptFormStore.getState().setPromptText(prompt.text);
81 | usePromptFormStore.getState().setWidth(prompt.w);
82 | usePromptFormStore.getState().setHeight(prompt.h);
83 | usePromptFormStore.getState().setUrlImage(prompt.input_image);
84 | usePromptFormStore.getState().setControlNet(prompt.controlnet);
85 | usePromptFormStore.getState().setColorGrading(prompt.color_grading);
86 | usePromptFormStore.getState().setFilmGrain(prompt.film_grain);
87 | usePromptFormStore.getState().setSuperResolution(prompt.super_resolution);
88 | usePromptFormStore.getState().setHiresFix(prompt.hires_fix);
89 | usePromptFormStore.getState().setInpaintFaces(prompt.inpaint_faces);
90 | usePromptFormStore.getState().setFaceCorrect(prompt.face_correct);
91 | usePromptFormStore.getState().setFaceSwap(prompt.face_swap);
92 | usePromptFormStore.getState().setDenoisingStrength(prompt.denoising_strength);
93 | usePromptFormStore.getState().setConditioningScale(prompt.controlnet_conditioning_scale);
94 | usePromptFormStore.getState().setNumImages(prompt.num_images);
95 | }
96 | }
97 | }));
98 |
--------------------------------------------------------------------------------
/src/pages/UsersPrompts.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback } from "react";
2 | import { Loader2 } from "lucide-react";
3 | import WithLayout from "@/components/WithLayout";
4 | import PromptCard from "@/components/PromptCard";
5 | import PromptForm from "@/components/PromptForm";
6 | import usePrompts from "@/hooks/usePrompts";
7 | import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
8 | import InfiniteScroll from "react-infinite-scroll-component";
9 | import { format, isToday, isYesterday, parseISO } from "date-fns";
10 |
11 | const UsersPrompts: React.FC = () => {
12 | const { userPrompts, fetchMoreData } = usePrompts();
13 |
14 | const [hasMore, setHasMore] = useState(true);
15 | const [loading, setLoading] = useState(false);
16 | const [error, setError] = useState(null);
17 | console.log(error);
18 | const loadMorePrompts = useCallback(async () => {
19 | if (loading) return;
20 | setLoading(true);
21 | setError(null);
22 | try {
23 | const hasMoreData = await fetchMoreData(false);
24 | setHasMore(hasMoreData);
25 | } catch (error) {
26 | setError("An error occurred while loading more prompts. Please try again.");
27 | console.error(error);
28 | } finally {
29 | setLoading(false);
30 | }
31 | }, [fetchMoreData, loading]);
32 |
33 | const lastPromptElementRef = useInfiniteScroll({
34 | loading,
35 | hasMore,
36 | loadMore: loadMorePrompts,
37 | });
38 |
39 | // Helper function to format date headers
40 | const formatDateHeader = (dateString: string) => {
41 | const date = parseISO(dateString);
42 | if (isToday(date)) return "Today";
43 | if (isYesterday(date)) return "Yesterday";
44 | return format(date, "dd MMM yyyy");
45 | };
46 |
47 | // Group prompts by date
48 | const groupedPrompts = userPrompts.reduce((acc, prompt, index) => {
49 | const dateHeader = formatDateHeader(prompt.created_at);
50 | if (!acc[dateHeader]) acc[dateHeader] = [];
51 | acc[dateHeader].push({ ...prompt, originalIndex: index });
52 | return acc;
53 | }, {} as Record);
54 |
55 | return (
56 |
57 |
58 |
59 |
}
64 | endMessage={
65 |
66 | Yay! You have seen it all
67 |
68 | }
69 | scrollThreshold={0.8}
70 | style={{ overflow: "hidden" }}
71 | >
72 |
73 | {Object.entries(groupedPrompts).map(([dateHeader, groupedPrompts]) => (
74 |
75 | {dateHeader}
76 | {groupedPrompts.map((prompt, index) => (
77 |
83 | ))}
84 |
85 | ))}
86 |
87 |
88 |
89 |
90 | );
91 | };
92 |
93 | const UsersPromptsWithLayout = WithLayout(UsersPrompts);
94 | export default UsersPromptsWithLayout;
--------------------------------------------------------------------------------
/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, useLocation } from 'react-router-dom';
3 | import { cn } from '@/lib/utils';
4 | import { PhotoIcon, HomeIcon } from '@heroicons/react/24/outline';
5 | import { GitHubLogoIcon } from '@radix-ui/react-icons';
6 |
7 | interface NavItem {
8 | to: string;
9 | label: string;
10 | Icon: React.ComponentType>;
11 | }
12 |
13 | const navItems: NavItem[] = [
14 | { to: '/', label: 'My Prompts', Icon: HomeIcon },
15 | { to: '/gallery', label: 'Gallery', Icon: PhotoIcon },
16 | ];
17 |
18 | const Navbar: React.FC = () => {
19 | const location = useLocation();
20 |
21 | const computeActive = (path: string) => {
22 | if (location.state?.type === "user") {
23 | return '/' === path;
24 | } else if (location.state?.type === "gallery") {
25 | return '/gallery' === path;
26 | }
27 | return location.pathname === path;
28 | };
29 |
30 | return (
31 |
65 | );
66 | };
67 |
68 | interface NavLinkProps extends NavItem {
69 | active: boolean;
70 | }
71 |
72 | const NavLink: React.FC = ({ to, label, Icon, active }) => (
73 |
80 |
81 | {label}
82 |
83 | );
84 |
85 | export default Navbar;
86 |
--------------------------------------------------------------------------------
/src/components/PromptDetails.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { DownloadIcon, CheckIcon, CopyIcon, Heart, Type } from "lucide-react";
3 | import { PromptDetailsProps } from "@/types";
4 | import { useCopy } from "@/hooks/useCopy";
5 | import { useLike } from "@/hooks/useLike";
6 | import { useAPrompt } from "@/hooks/useAPrompt";
7 |
8 |
9 | const PromptDetails: React.FC = ({ prompt, imageUrl }) => {
10 | const { isLiked: initialIsLiked, handleLike } = useLike(prompt?.liked);
11 | const { isCopied, handleCopy } = useCopy();
12 | const { isPromptUsed, handleUsePrompt } = useAPrompt();
13 |
14 | // Local state to track like status and like count
15 | const [isLiked, setIsLiked] = useState(initialIsLiked);
16 |
17 | const toggleLike = (e: React.MouseEvent) => {
18 | e.stopPropagation();
19 | // Update the like status and like count in the UI
20 | setIsLiked((prev) => !prev);
21 | // Call the hook's handleLike function to handle backend update
22 | handleLike(prompt.id);
23 | };
24 |
25 | // Function to handle downloading the prompt image
26 | const handleDownload = () => {
27 | if (imageUrl) {
28 | const element = document.createElement("a");
29 | element.href = imageUrl;
30 | element.download = `${prompt.id}.png`;
31 | element.target = "_blank";
32 | document.body.appendChild(element);
33 | element.click();
34 | document.body.removeChild(element);
35 | }
36 | };
37 |
38 | return (
39 |
40 |
41 |
42 |
{prompt?.id}
43 |
44 |
50 |
61 |
75 |
82 |
83 |
84 |
85 | {prompt?.text?.length > 100 ? `${prompt?.text.slice(0, 200)}...` : prompt?.text}
86 |
87 |
88 |
89 |
90 |
91 | );
92 | };
93 |
94 | export default PromptDetails;
95 |
--------------------------------------------------------------------------------
/src/components/ui/bidirectional-slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SliderPrimitive from "@radix-ui/react-slider"
3 | import { cn } from "@/lib/utils"
4 |
5 | interface BidirectionalSliderProps {
6 | value?: number // New prop to control the slider externally
7 | defaultValue?: number
8 | max?: number
9 | step?: number
10 | onValueChange?: (value: number) => void
11 | className?: string
12 | }
13 |
14 | const BidirectionalSlider = React.forwardRef<
15 | React.ElementRef,
16 | BidirectionalSliderProps
17 | >(({
18 | value: controlledValue, // renamed to avoid conflict
19 | defaultValue = 0,
20 | max = 100,
21 | step = 1,
22 | onValueChange,
23 | className
24 | }, ref) => {
25 | const [internalValue, setInternalValue] = React.useState(defaultValue)
26 |
27 | // Determine whether we're using controlled or uncontrolled mode
28 | const isControlled = controlledValue !== undefined
29 | const currentValue = isControlled ? controlledValue : internalValue
30 |
31 | const handleValueChange = (newValue: number[]) => {
32 | const singleValue = newValue[0]
33 | if (!isControlled) {
34 | setInternalValue(singleValue)
35 | }
36 | onValueChange?.(singleValue)
37 | }
38 |
39 | const activeTrackWidth = `${(Math.abs(currentValue) / max) * 50}%`
40 |
41 | const getTrackColors = () => {
42 | return currentValue === 0
43 | ? 'bg-gray-300 dark:bg-gray-700'
44 | : currentValue > 0
45 | ? 'bg-gray-700 dark:bg-gray-300'
46 | : 'bg-gray-500 dark:bg-gray-500'
47 | }
48 |
49 | const getThumbColors = () => {
50 | return currentValue === 0
51 | ? 'border-gray-400 hover:border-gray-500 dark:border-gray-500 dark:hover:border-gray-400'
52 | : currentValue > 0
53 | ? 'border-gray-600 hover:border-gray-700 dark:border-gray-300 dark:hover:border-gray-400'
54 | : 'border-gray-500 hover:border-gray-600 dark:border-gray-500 dark:hover:border-gray-400'
55 | }
56 |
57 | return (
58 |
59 |
71 | {/* Background track */}
72 |
73 | {/* Center line */}
74 |
75 |
76 | {/* Active track */}
77 | 0 ? 'translateX(0)' : 'translateX(-100%)',
85 | }}
86 | />
87 |
88 |
89 | {/* Thumb */}
90 |
100 |
101 |
102 | )
103 | })
104 | BidirectionalSlider.displayName = "BidirectionalSlider"
105 |
106 | export { BidirectionalSlider }
107 |
--------------------------------------------------------------------------------
/src/components/PromptCard/PropertiesDisplay.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Prompt } from "@/types";
3 | import { Paperclip } from "lucide-react";
4 |
5 | interface PropertiesDisplayProps {
6 | prompt: Prompt;
7 | }
8 |
9 | type DisplayProperty = {
10 | key: keyof Prompt | [keyof Prompt, keyof Prompt]; // Single key or tuple for combination
11 | label: string;
12 | extraText?: string;
13 | link?: string;
14 | joiner?: string;
15 | };
16 |
17 | const displayProperties: DisplayProperty[] = [
18 | { key: "style", label: "Style" },
19 | { key: "cfg_scale", label: "Scale" },
20 | { key: "seed", label: "Seed" },
21 | { key: "steps", label: "Steps" },
22 | { key: ["w", "h"], label: "Size", joiner: "x" }, // Combined w and h with "x" as joiner
23 | { key: "scheduler", label: "Scheduler" },
24 | { key: "color_grading", label: "Color Grading" },
25 | { key: "film_grain", label: "Film Grain" },
26 | { key: "super_resolution", label: "Super-Resolution" },
27 | {
28 | key: "only_upscale",
29 | label: "Upscale",
30 | link: "https://docs.astria.ai/docs/use-cases/upscale",
31 | },
32 | {
33 | key: "tiled_upscale",
34 | label: "Tiled Upscale",
35 | link: "https://docs.astria.ai/docs/features/tiled-upscale",
36 | },
37 | { key: "hires_fix", label: "HiRes Fix" },
38 | { key: "face_correct", label: "Face Correct" },
39 | {
40 | key: "face_swap",
41 | label: "Face Swap",
42 | link: "https://docs.astria.ai/docs/features/face-swap",
43 | },
44 | {
45 | key: "inpaint_faces",
46 | label: "Inpaint Faces",
47 | link: "https://docs.astria.ai/docs/features/face-inpainting",
48 | },
49 | { key: "is_multiperson", label: "Multiperson" },
50 | { key: "prompt_expansion", label: "Prompt Expansion" },
51 | { key: "theme", label: "Theme" },
52 | { key: "mask_image", label: "Mask" },
53 | {
54 | key: "controlnet",
55 | label: "ControlNet",
56 | link: "https://docs.astria.ai/docs/use-cases/controlnet",
57 | },
58 | { key: "use_lpw", label: "Weighted" },
59 | ];
60 |
61 | export const PropertiesDisplay: React.FC = ({
62 | prompt,
63 | }) => {
64 | // console.log("prompt", { prompt });
65 | return (
66 | <>
67 | {prompt.input_image && (
68 |
69 |
70 |

71 |
72 | )}
73 |
74 | {displayProperties.map(({ key, label, extraText, link, joiner }) => {
75 | let displayValue;
76 |
77 | if (Array.isArray(key)) {
78 | // For combined keys, join their values if both are present
79 | const [key1, key2] = key;
80 | const value1 = prompt[key1];
81 | const value2 = prompt[key2];
82 | if (value1 && value2) {
83 | displayValue = `${value1}${joiner || ""}${value2}`;
84 | }
85 | } else {
86 | // For single keys, just get the value
87 | displayValue = prompt[key];
88 | }
89 |
90 | if (!displayValue) return null;
91 |
92 | return (
93 |
97 | {label}: {displayValue}
98 | {extraText || ""}
99 | {link && (
100 |
106 | Docs
107 |
108 | )}
109 |
110 | );
111 | })}
112 |
113 | >
114 | );
115 | };
116 |
--------------------------------------------------------------------------------
/src/components/PromptForm/PromptForm.tsx:
--------------------------------------------------------------------------------
1 | import React, {useRef, useEffect } from "react";
2 | import { Card, CardContent } from "@/components/ui/card";
3 | import { Textarea } from "@/components/ui/textarea";
4 | import { ImageUpload } from "./ImageUpload";
5 | import { PromptControls } from "./PromptControls";
6 | import { usePromptSubmit } from "@/hooks/usePromptSubmit";
7 | import { usePromptForm } from "@/hooks/usePromptForm";
8 | import { ToastContainer } from "react-toastify";
9 | import "react-toastify/dist/ReactToastify.css";
10 | import { AdvancedControls } from "./AdvancedControls";
11 |
12 | const PromptForm: React.FC = () => {
13 | const {
14 | showAdvancedControls,
15 | showImageControls,
16 | promptText,
17 | isLoading,
18 | handlePaste,
19 | setPromptText,
20 | handleImageUpload,
21 | toggleImageControls,
22 | toggleAdvancedControls,
23 | setShowAdvancedControls,
24 | } = usePromptForm();
25 |
26 | const { handleSubmit } = usePromptSubmit();
27 |
28 | useEffect(() => {
29 | const handleKeyDown = (e: KeyboardEvent) => {
30 | if (e.key === "Enter" && !isLoading && promptText.trim()) {
31 | e.preventDefault();
32 | handleSubmit();
33 | }
34 | };
35 |
36 | window.addEventListener("keydown", handleKeyDown);
37 | return () => window.removeEventListener("keydown", handleKeyDown);
38 | }, [promptText, isLoading, handleSubmit]);
39 |
40 | useEffect(() => {
41 | window.addEventListener("paste", handlePaste);
42 | return () => {
43 | window.removeEventListener("paste", handlePaste);
44 | };
45 | }, [handlePaste]);
46 |
47 | const textareaRef = useRef(null);
48 |
49 | useEffect(() => {
50 | if (textareaRef.current) {
51 | const target = textareaRef.current;
52 | target.style.height = "auto";
53 | target.style.height = `${target.scrollHeight}px`;
54 | }
55 | }, [promptText]);
56 |
57 | return (
58 |
59 |
60 |
61 |
62 |
91 |
92 |
93 |
94 | {showAdvancedControls && (
95 | <>
96 |
setShowAdvancedControls(false)}
99 | />
100 |
101 |
102 |
103 |
104 |
105 | >
106 | )}
107 |
108 |
109 |
110 | );
111 | };
112 |
113 | export default PromptForm;
--------------------------------------------------------------------------------
/src/hooks/usePrompts.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback, useRef } from "react";
2 | import { useStore } from "@/store/promptStore";
3 | import { fetchGalleryPrompts, fetchUserPrompts } from "@/api/prompts";
4 | import type { Prompt } from "../types";
5 | import { AxiosError } from "axios";
6 | import { toast } from "react-toastify";
7 |
8 |
9 | export type PromptType = "user" | "gallery" | null;
10 |
11 | interface UsePromptsResult {
12 | galleryPrompts: Prompt[];
13 | userPrompts: Prompt[];
14 | fetchMoreData: (isGallery: boolean) => Promise
;
15 | findPrompt: (promptId: number) => Promise;
16 | }
17 |
18 | const usePrompts = (promptId?: number): UsePromptsResult => {
19 | const {
20 | galleryPrompts,
21 | userPrompts,
22 | galleryOffset,
23 | userOffset,
24 | limit,
25 | addGalleryPrompts,
26 | addUserPrompts,
27 | } = useStore();
28 |
29 | const initialized = useRef(false);
30 |
31 | const fetchMoreData = useCallback(async (isGallery: boolean): Promise => {
32 | try {
33 | const prompts = isGallery
34 | ? await fetchGalleryPrompts(galleryOffset, limit)
35 | : await fetchUserPrompts(userOffset, limit);
36 |
37 | if (!prompts?.length) {
38 | return false;
39 | }
40 |
41 | if (isGallery) {
42 | addGalleryPrompts(prompts);
43 | } else {
44 | addUserPrompts(prompts);
45 | }
46 | return true;
47 | } catch (error: unknown) {
48 | console.error(`Error fetching ${isGallery ? "gallery" : "user"} prompts:`, error);
49 | if ((error as AxiosError).response?.status === 401) {
50 | toast.error("Please log in to continue");
51 | window.location.href = "https://www.astria.ai/users/sign_in";
52 | }
53 | return false;
54 | }
55 | }, [galleryOffset, userOffset, limit, addGalleryPrompts, addUserPrompts]);
56 |
57 | const findPrompt = useCallback(async (searchPromptId: number): Promise => {
58 | const checkPrompts = () => {
59 | const foundInUser = userPrompts.some((prompt) => prompt.id === searchPromptId);
60 | const foundInGallery = galleryPrompts.some((prompt) => prompt.id === searchPromptId);
61 | return { foundInUser, foundInGallery };
62 | };
63 |
64 | let { foundInUser, foundInGallery } = checkPrompts();
65 | if (foundInUser) return "user";
66 | if (foundInGallery) return "gallery";
67 |
68 | let userExhausted = false;
69 | let galleryExhausted = false;
70 | let attemptCount = 0; // Limit attempts to avoid infinite loop
71 |
72 | while (!userExhausted || !galleryExhausted) {
73 | attemptCount += 1;
74 | if (attemptCount > 5) break; // Limit fetch attempts
75 |
76 | const userPromise: Promise = !userExhausted
77 | ? fetchMoreData(false)
78 | : Promise.resolve(false);
79 |
80 | const galleryPromise: Promise = !galleryExhausted
81 | ? fetchMoreData(true)
82 | : Promise.resolve(false);
83 |
84 | const [hasMoreUserPrompts, hasMoreGalleryPrompts] = await Promise.all([
85 | userPromise,
86 | galleryPromise,
87 | ]);
88 |
89 | ({ foundInUser, foundInGallery } = checkPrompts());
90 | if (foundInUser) return "user";
91 | if (foundInGallery) return "gallery";
92 |
93 | userExhausted = !hasMoreUserPrompts;
94 | galleryExhausted = !hasMoreGalleryPrompts;
95 | }
96 |
97 | return null;
98 | }, [userPrompts, galleryPrompts, fetchMoreData]);
99 |
100 |
101 | useEffect(() => {
102 | const initializePrompts = async () => {
103 | if (initialized.current) return;
104 | initialized.current = true;
105 |
106 | if (promptId) {
107 | await findPrompt(promptId);
108 | }
109 |
110 | if (galleryPrompts.length === 0) {
111 | await fetchMoreData(true);
112 | }
113 | if (userPrompts.length === 0) {
114 | await fetchMoreData(false);
115 | }
116 | };
117 |
118 | initializePrompts();
119 | }, [promptId, fetchMoreData, findPrompt, galleryPrompts.length, userPrompts.length]);
120 |
121 | return {
122 | galleryPrompts,
123 | userPrompts,
124 | fetchMoreData,
125 | findPrompt,
126 | };
127 | };
128 |
129 | export default usePrompts;
130 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { Cross2Icon } from "@radix-ui/react-icons"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ))
52 | DialogContent.displayName = DialogPrimitive.Content.displayName
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | )
66 | DialogHeader.displayName = "DialogHeader"
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | )
80 | DialogFooter.displayName = "DialogFooter"
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ))
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogTrigger,
114 | DialogClose,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | }
121 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface Prompt {
2 | id: number;
3 | callback: string | null;
4 | trained_at: string;
5 | started_training_at: string;
6 | created_at: string;
7 | updated_at: string;
8 | tune_id: number;
9 | text: string;
10 | negative_prompt: string;
11 | cfg_scale: number | null;
12 | steps: number | null;
13 | super_resolution: boolean;
14 | ar: string;
15 | num_images: number;
16 | seed: number | null;
17 | controlnet_conditioning_scale: number;
18 | controlnet_txt2img: boolean;
19 | denoising_strength: number;
20 | style: string | null;
21 | url: string;
22 | images: string[];
23 | prompt_likes_count: number;
24 | liked: boolean;
25 | w: number;
26 | h: number;
27 | scheduler: string | null;
28 | color_grading: string;
29 | film_grain: boolean;
30 | only_upscale: boolean;
31 | tiled_upscale: boolean;
32 | hires_fix: boolean;
33 | face_correct: boolean;
34 | face_swap: boolean;
35 | inpaint_faces: boolean;
36 | is_multiperson: boolean;
37 | prompt_expansion: boolean;
38 | theme: string | null;
39 | input_image: string | null;
40 | mask_image: string | null;
41 | controlnet: string;
42 | use_lpw: boolean;
43 | backend_version: string;
44 | }
45 |
46 | export interface Tune {
47 | id: number;
48 | callback: string | null;
49 | created_at: string;
50 | eta: string;
51 | expires_at: string;
52 | is_api: boolean;
53 | model_type: string;
54 | name: string;
55 | orig_images: string[];
56 | started_training_at: string;
57 | title: string;
58 | token: string;
59 | trained_at: string;
60 | updated_at: string;
61 | url: string;
62 | }
63 |
64 | export interface PromptsState {
65 | galleryPrompts: Prompt[];
66 | userPrompts: Prompt[];
67 | galleryOffset: number;
68 | userOffset: number;
69 | limit: number;
70 | addGalleryPrompts: (prompts: Prompt[]) => void;
71 | addUserPrompts: (prompts: Prompt[]) => void;
72 | resetGalleryPrompts: () => void;
73 | resetUserPrompts: () => void;
74 | refreshGalleryPrompts: () => Promise;
75 | refreshUserPrompts: () => Promise;
76 | retrieveSinglePrompt: (tuneId: number, promptId: number) => Promise;
77 | removeSinglePrompt: (promptId: number) => void;
78 | updateSinglePrompt: (tuneId: number, promptId: number) => Promise;
79 | updatePromptForm: (prompt: Prompt) => void;
80 | }
81 |
82 |
83 | export interface PromptDetailsProps {
84 | prompt: Prompt;
85 | imageUrl: string;
86 | }
87 |
88 | export type ErrorObject = {
89 | [key in keyof Prompt]?: string[];
90 | };
91 |
92 |
93 | export interface PromptFormState {
94 | promptText: string;
95 | setPromptText: (text: string) => void;
96 |
97 | width: number;
98 | setWidth: (width: number) => void;
99 | height: number;
100 | setHeight: (height: number) => void;
101 |
102 | controlNet: string;
103 | setControlNet: (controlNet: string) => void;
104 | colorGrading: string;
105 | setColorGrading: (colorGrading: string) => void;
106 | filmGrain: boolean;
107 | setFilmGrain: (filmGrain: boolean) => void;
108 | controlNetTXT2IMG: boolean;
109 | setControlNetTXT2IMG: (controlNetTXT2IMG: boolean) => void;
110 |
111 | superResolution: boolean;
112 | setSuperResolution: (superResolution: boolean) => void;
113 | hiresFix: boolean;
114 | setHiresFix: (hiresFix: boolean) => void;
115 | inpaintFaces: boolean;
116 | setInpaintFaces: (inpaintFaces: boolean) => void;
117 | faceCorrect: boolean;
118 | setFaceCorrect: (faceCorrect: boolean) => void;
119 | faceSwap: boolean;
120 | setFaceSwap: (faceSwap: boolean) => void;
121 |
122 | backendVersion: string;
123 | setBackendVersion: (backendVersion: string) => void;
124 | denoisingStrength: number;
125 | setDenoisingStrength: (denoisingStrength: number) => void;
126 | conditioningScale: number;
127 | setConditioningScale: (conditioningScale: number) => void;
128 | numImages: number;
129 | setNumImages: (numImages: number) => void;
130 | steps: number | null;
131 | setSteps: (steps: number) => void;
132 | seed: number | null;
133 | setSeed: (seed: number) => void;
134 |
135 | loraTextList: string[];
136 | setLoraTextList: (loraTextList: string[]) => void;
137 |
138 | error: ErrorObject;
139 | setError: (error: ErrorObject) => void;
140 | isLoading: boolean;
141 | setIsLoading: (isLoading: boolean) => void;
142 |
143 | // Add image-related state
144 | image: File | null;
145 | setImage: (image: File | null) => void;
146 | urlImage: string | null;
147 | setUrlImage: (urlImage: string | null) => void;
148 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Flux Imagine GUI interface
2 |
3 | A modern and intuitive graphical user interface for generating images using Flux technology. This web-based application provides a seamless experience for creating, editing, and managing AI-generated images through a clean and responsive interface. Built with React and TypeScript, it offers real-time previews, batch processing capabilities, and integration with Flux's powerful image generation API.
4 |
5 |
6 | ## Table of Contents
7 |
8 | - [Features](#features)
9 | - [Demo](#demo)
10 | - [Installation](#installation)
11 | - [Usage](#usage)
12 | - [API Endpoints](#api-endpoints)
13 | - [Project Structure](#project-structure)
14 | - [Environment Variables](#environment-variables)
15 | - [Deployment](#deployment)
16 | - [Contributing](#contributing)
17 | - [License](#license)
18 |
19 | ## Deploy to Vercel
20 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fastriaai%2Fimagine&env=ASTRIA_API_KEY)
21 |
22 | ### Vercel Deployment ENV
23 | On click of the deployment button fill the env accordingly:
24 |
25 | ```plaintext
26 | ASTRIA_API_KEY=fill your astria api key
27 | ```
28 |
29 | ## Installation
30 |
31 | To get a local copy up and running, follow these steps.
32 |
33 | ### Prerequisites
34 |
35 | - **Node.js** and **npm**
36 | - **Git**
37 |
38 | ### Steps
39 |
40 | 1. Clone the repository:
41 |
42 | ```bash
43 | git clone https://github.com/astriaai/imagine.git
44 | cd imagine
45 | ```
46 |
47 | 2. Install the dependencies:
48 |
49 | ```bash
50 | npm install
51 | ```
52 |
53 | 3. Set up environment variables:
54 |
55 | Create a `.env` file in the root directory and add your API key:
56 |
57 | ```plaintext
58 | ASTRIA_API_KEY=fill your astria api key
59 | ```
60 |
61 | ## Usage
62 |
63 | After installation, you can run the project locally with:
64 |
65 | ```bash
66 | npm run dev
67 | ```
68 |
69 | The app should now be running on `http://localhost:5173`.
70 |
71 | ## Project Structure
72 |
73 | The project follows this folder structure for maintainability:
74 |
75 | ```plaintext
76 | src/
77 | ├── api/ # API configuration and endpoints
78 | ├── components/ # Reusable components and UI elements
79 | ├── hooks/ # Custom React hooks
80 | ├── lib/ # Utility functions and helpers
81 | ├── pages/ # Main application pages and routes
82 | ├── services/ # API calls (Axios instances and API functions)
83 | ├── store/ # State management (Zustand)
84 | └── types/ # TypeScript types and interfaces
85 | ```
86 |
87 | Each directory serves a specific purpose:
88 |
89 | - `api/`: Contains API configuration, endpoints, and related utilities
90 | - `components/`: Houses reusable UI components used throughout the application
91 | - `hooks/`: Custom React hooks for shared functionality
92 | - `lib/`: Helper functions, constants, and utility code
93 | - `pages/`: Main application pages and routing components
94 | - `services/`: API integration layer with Axios configurations
95 | - `store/`: State management logic using Zustand
96 | - `types/`: TypeScript type definitions and interfaces
97 |
98 |
99 | ## Deployment
100 |
101 | This project can be deployed to GitHub Pages or any other static site hosting service.
102 |
103 | ### GitHub Pages Deployment
104 |
105 | 1. Install `gh-pages`:
106 |
107 | ```bash
108 | npm install gh-pages --save-dev
109 | ```
110 |
111 | 2. Deploy the app:
112 |
113 | ```bash
114 | npm run build
115 | npm run deploy
116 | ```
117 |
118 | The app should now be live on GitHub Pages!
119 |
120 | ## Contributing
121 |
122 | Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
123 |
124 | 1. Fork the Project
125 | 2. Create your Feature Branch (`git checkout -b feature/YourFeature`)
126 | 3. Commit your Changes (`git commit -m 'Add YourFeature'`)
127 | 4. Push to the Branch (`git push origin feature/YourFeature`)
128 | 5. Open a Pull Request
129 |
130 | ## Sponsored by Astria
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 | This project is proudly sponsored by [Astria](https://astria.ai), a leading platform in AI-powered image generation and manipulation.
139 |
140 | ## License
141 |
142 | Distributed under the MIT License. See `LICENSE` for more information.
143 |
144 |
--------------------------------------------------------------------------------
/src/hooks/usePromptNavigation.ts:
--------------------------------------------------------------------------------
1 |
2 | // hooks/usePromptNavigation.ts
3 | import { useEffect, useCallback } from 'react';
4 | import { useNavigate } from 'react-router-dom';
5 | import { Prompt } from '../types';
6 |
7 | interface PromptNavigationHookResult {
8 | navigateToNext: () => void;
9 | navigateToPrevious: () => void;
10 | }
11 |
12 | interface UsePromptNavigationProps {
13 | currentPromptIndex: number;
14 | prompts: Prompt[];
15 | type: string;
16 | currentImageIndex: number;
17 | }
18 |
19 | export const usePromptNavigation = ({
20 | currentPromptIndex,
21 | prompts,
22 | type,
23 | currentImageIndex,
24 | }: UsePromptNavigationProps): PromptNavigationHookResult => {
25 | const navigate = useNavigate();
26 |
27 | const navigateToNext = useCallback(() => {
28 | const currentPrompt = prompts[currentPromptIndex];
29 | if (!currentPrompt) return;
30 |
31 | const nextImageIndex = currentImageIndex + 1;
32 |
33 | // Check if we can move to the next image in the current prompt
34 | if (nextImageIndex < currentPrompt.images.length) {
35 | navigate(`/prompt/${currentPrompt.id}/${nextImageIndex}`, {
36 | state: { type },
37 | replace: true,
38 | });
39 | return;
40 | }
41 |
42 | // Move to the next prompt
43 | const nextPromptIndex = currentPromptIndex + 1;
44 | if (nextPromptIndex < prompts.length) {
45 | navigate(`/prompt/${prompts[nextPromptIndex].id}/0`, {
46 | state: { type },
47 | replace: true,
48 | });
49 | }
50 | }, [currentPromptIndex, currentImageIndex, prompts, type, navigate]);
51 |
52 | const navigateToPrevious = useCallback(() => {
53 | const currentPrompt = prompts[currentPromptIndex];
54 | if (!currentPrompt) return;
55 |
56 | const previousImageIndex = currentImageIndex - 1;
57 |
58 | // Check if we can move to the previous image in the current prompt
59 | if (previousImageIndex >= 0) {
60 | navigate(`/prompt/${currentPrompt.id}/${previousImageIndex}`, {
61 | state: { type },
62 | replace: true,
63 | });
64 | return;
65 | }
66 |
67 | // Move to the previous prompt
68 | const previousPromptIndex = currentPromptIndex - 1;
69 | if (previousPromptIndex >= 0) {
70 | const previousPrompt = prompts[previousPromptIndex];
71 | navigate(
72 | `/prompt/${previousPrompt.id}/${previousPrompt.images.length - 1}`,
73 | {
74 | state: { type },
75 | replace: true,
76 | }
77 | );
78 | }
79 | }, [currentPromptIndex, currentImageIndex, prompts, type, navigate]);
80 |
81 | useEffect(() => {
82 | let touchStartX = 0;
83 |
84 | const handleKeyDown = (event: KeyboardEvent) => {
85 | switch (event.key) {
86 | case 'ArrowRight':
87 | case 'ArrowDown':
88 | event.preventDefault();
89 | navigateToNext();
90 | break;
91 | case 'ArrowLeft':
92 | case 'ArrowUp':
93 | event.preventDefault();
94 | navigateToPrevious();
95 | break;
96 | }
97 | };
98 |
99 | const handleWheel = (event: WheelEvent) => {
100 | event.preventDefault();
101 | if (event.deltaY > 0) {
102 | navigateToNext();
103 | } else if (event.deltaY < 0) {
104 | navigateToPrevious();
105 | }
106 | };
107 |
108 | const handleTouchStart = (event: TouchEvent) => {
109 | touchStartX = event.touches[0].clientX;
110 | };
111 |
112 | const handleTouchEnd = (event: TouchEvent) => {
113 | const touchEndX = event.changedTouches[0].clientX;
114 | const SWIPE_THRESHOLD = 50;
115 |
116 | if (touchStartX - touchEndX > SWIPE_THRESHOLD) {
117 | navigateToNext();
118 | } else if (touchEndX - touchStartX > SWIPE_THRESHOLD) {
119 | navigateToPrevious();
120 | }
121 | };
122 |
123 | // Add event listeners with passive: false for better scroll performance
124 | window.addEventListener('keydown', handleKeyDown);
125 | window.addEventListener('wheel', handleWheel, { passive: false });
126 | window.addEventListener('touchstart', handleTouchStart, { passive: true });
127 | window.addEventListener('touchend', handleTouchEnd, { passive: true });
128 |
129 | return () => {
130 | window.removeEventListener('keydown', handleKeyDown);
131 | window.removeEventListener('wheel', handleWheel);
132 | window.removeEventListener('touchstart', handleTouchStart);
133 | window.removeEventListener('touchend', handleTouchEnd);
134 | };
135 | }, [navigateToNext, navigateToPrevious]);
136 |
137 | return { navigateToNext, navigateToPrevious };
138 | };
139 |
--------------------------------------------------------------------------------
/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import type {
4 | ToastActionElement,
5 | ToastProps,
6 | } from "@/components/ui/toast"
7 |
8 | const TOAST_LIMIT = 1
9 | const TOAST_REMOVE_DELAY = 1000000
10 |
11 | type ToasterToast = ToastProps & {
12 | id: string
13 | title?: React.ReactNode
14 | description?: React.ReactNode
15 | action?: ToastActionElement
16 | }
17 |
18 | const actionTypes = {
19 | ADD_TOAST: "ADD_TOAST",
20 | UPDATE_TOAST: "UPDATE_TOAST",
21 | DISMISS_TOAST: "DISMISS_TOAST",
22 | REMOVE_TOAST: "REMOVE_TOAST",
23 | } as const
24 |
25 | let count = 0
26 |
27 | function genId() {
28 | count = (count + 1) % Number.MAX_SAFE_INTEGER
29 | return count.toString()
30 | }
31 |
32 | type ActionType = typeof actionTypes
33 |
34 | type Action =
35 | | {
36 | type: ActionType["ADD_TOAST"]
37 | toast: ToasterToast
38 | }
39 | | {
40 | type: ActionType["UPDATE_TOAST"]
41 | toast: Partial
42 | }
43 | | {
44 | type: ActionType["DISMISS_TOAST"]
45 | toastId?: ToasterToast["id"]
46 | }
47 | | {
48 | type: ActionType["REMOVE_TOAST"]
49 | toastId?: ToasterToast["id"]
50 | }
51 |
52 | interface State {
53 | toasts: ToasterToast[]
54 | }
55 |
56 | const toastTimeouts = new Map>()
57 |
58 | const addToRemoveQueue = (toastId: string) => {
59 | if (toastTimeouts.has(toastId)) {
60 | return
61 | }
62 |
63 | const timeout = setTimeout(() => {
64 | toastTimeouts.delete(toastId)
65 | dispatch({
66 | type: "REMOVE_TOAST",
67 | toastId: toastId,
68 | })
69 | }, TOAST_REMOVE_DELAY)
70 |
71 | toastTimeouts.set(toastId, timeout)
72 | }
73 |
74 | export const reducer = (state: State, action: Action): State => {
75 | switch (action.type) {
76 | case "ADD_TOAST":
77 | return {
78 | ...state,
79 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
80 | }
81 |
82 | case "UPDATE_TOAST":
83 | return {
84 | ...state,
85 | toasts: state.toasts.map((t) =>
86 | t.id === action.toast.id ? { ...t, ...action.toast } : t
87 | ),
88 | }
89 |
90 | case "DISMISS_TOAST": {
91 | const { toastId } = action
92 |
93 | // ! Side effects ! - This could be extracted into a dismissToast() action,
94 | // but I'll keep it here for simplicity
95 | if (toastId) {
96 | addToRemoveQueue(toastId)
97 | } else {
98 | state.toasts.forEach((toast) => {
99 | addToRemoveQueue(toast.id)
100 | })
101 | }
102 |
103 | return {
104 | ...state,
105 | toasts: state.toasts.map((t) =>
106 | t.id === toastId || toastId === undefined
107 | ? {
108 | ...t,
109 | open: false,
110 | }
111 | : t
112 | ),
113 | }
114 | }
115 | case "REMOVE_TOAST":
116 | if (action.toastId === undefined) {
117 | return {
118 | ...state,
119 | toasts: [],
120 | }
121 | }
122 | return {
123 | ...state,
124 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
125 | }
126 | }
127 | }
128 |
129 | const listeners: Array<(state: State) => void> = []
130 |
131 | let memoryState: State = { toasts: [] }
132 |
133 | function dispatch(action: Action) {
134 | memoryState = reducer(memoryState, action)
135 | listeners.forEach((listener) => {
136 | listener(memoryState)
137 | })
138 | }
139 |
140 | type Toast = Omit
141 |
142 | function toast({ ...props }: Toast) {
143 | const id = genId()
144 |
145 | const update = (props: ToasterToast) =>
146 | dispatch({
147 | type: "UPDATE_TOAST",
148 | toast: { ...props, id },
149 | })
150 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
151 |
152 | dispatch({
153 | type: "ADD_TOAST",
154 | toast: {
155 | ...props,
156 | id,
157 | open: true,
158 | onOpenChange: (open) => {
159 | if (!open) dismiss()
160 | },
161 | },
162 | })
163 |
164 | return {
165 | id: id,
166 | dismiss,
167 | update,
168 | }
169 | }
170 |
171 | function useToast() {
172 | const [state, setState] = React.useState(memoryState)
173 |
174 | React.useEffect(() => {
175 | listeners.push(setState)
176 | return () => {
177 | const index = listeners.indexOf(setState)
178 | if (index > -1) {
179 | listeners.splice(index, 1)
180 | }
181 | }
182 | }, [state])
183 |
184 | return {
185 | ...state,
186 | toast,
187 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
188 | }
189 | }
190 |
191 | export { useToast, toast }
192 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SheetPrimitive from "@radix-ui/react-dialog"
3 | import { Cross2Icon } from "@radix-ui/react-icons"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Sheet = SheetPrimitive.Root
9 |
10 | const SheetTrigger = SheetPrimitive.Trigger
11 |
12 | const SheetClose = SheetPrimitive.Close
13 |
14 | const SheetPortal = SheetPrimitive.Portal
15 |
16 | const SheetOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ))
29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
30 |
31 | const sheetVariants = cva(
32 | "fixed z-50 gap-4 bg-white 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 dark:bg-zinc-950",
33 | {
34 | variants: {
35 | side: {
36 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
37 | bottom:
38 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
39 | 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",
40 | right:
41 | "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",
42 | },
43 | },
44 | defaultVariants: {
45 | side: "right",
46 | },
47 | }
48 | )
49 |
50 | interface SheetContentProps
51 | extends React.ComponentPropsWithoutRef,
52 | VariantProps {}
53 |
54 | const SheetContent = React.forwardRef<
55 | React.ElementRef,
56 | SheetContentProps
57 | >(({ side = "right", className, children, ...props }, ref) => (
58 |
59 |
60 |
65 |
66 |
67 | Close
68 |
69 | {children}
70 |
71 |
72 | ))
73 | SheetContent.displayName = SheetPrimitive.Content.displayName
74 |
75 | const SheetHeader = ({
76 | className,
77 | ...props
78 | }: React.HTMLAttributes) => (
79 |
86 | )
87 | SheetHeader.displayName = "SheetHeader"
88 |
89 | const SheetFooter = ({
90 | className,
91 | ...props
92 | }: React.HTMLAttributes) => (
93 |
100 | )
101 | SheetFooter.displayName = "SheetFooter"
102 |
103 | const SheetTitle = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | SheetTitle.displayName = SheetPrimitive.Title.displayName
114 |
115 | const SheetDescription = React.forwardRef<
116 | React.ElementRef,
117 | React.ComponentPropsWithoutRef
118 | >(({ className, ...props }, ref) => (
119 |
124 | ))
125 | SheetDescription.displayName = SheetPrimitive.Description.displayName
126 |
127 | export {
128 | Sheet,
129 | SheetPortal,
130 | SheetOverlay,
131 | SheetTrigger,
132 | SheetClose,
133 | SheetContent,
134 | SheetHeader,
135 | SheetFooter,
136 | SheetTitle,
137 | SheetDescription,
138 | }
139 |
--------------------------------------------------------------------------------
/src/pages/Prompt.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useCallback } from "react";
2 | import { useParams, useLocation, useNavigate } from "react-router-dom";
3 | import { useStore } from "@/store/promptStore";
4 | import { usePromptNavigation } from "@/hooks/usePromptNavigation";
5 | import WithPrompt from "@/components/WithLayout";
6 | import PromptForm from "@/components/PromptForm";
7 | import PromptImage from "@/components/PromptImage";
8 | import PromptDetails from "@/components/PromptDetails";
9 | import ImageZoom from "@/components/ImageZoom";
10 | import usePrompts from "@/hooks/usePrompts";
11 |
12 | const Prompt: React.FC = () => {
13 | const { id, index: indexString } = useParams<{ id: string; index: string }>();
14 | const location = useLocation();
15 | const navigate = useNavigate();
16 | const { findPrompt } = usePrompts();
17 | const { galleryPrompts, userPrompts } = useStore();
18 |
19 | const [promptType, setPromptType] = useState(
20 | location.state?.type || ""
21 | );
22 | const [zoomImage, setZoomImage] = useState(false);
23 | const [isLoading, setIsLoading] = useState(false);
24 |
25 | const index = parseInt(indexString || "0", 10);
26 | const prompts = promptType === "gallery" ? galleryPrompts : userPrompts;
27 | const currentPromptIndex = prompts.findIndex(
28 | (prompt) => `${prompt?.id}` === id
29 | );
30 | const currentPrompt = prompts[currentPromptIndex];
31 | usePromptNavigation({
32 | currentPromptIndex,
33 | prompts,
34 | type: promptType,
35 | currentImageIndex: index,
36 | });
37 |
38 | const handleEscape = useCallback(
39 | (event: KeyboardEvent) => {
40 | if (event.key === "Escape") {
41 | event.preventDefault();
42 | event.stopPropagation();
43 |
44 | if (zoomImage) {
45 | setZoomImage(false);
46 | } else {
47 | navigate(promptType === "gallery" ? "/gallery" : "/", {
48 | replace: true,
49 | });
50 | }
51 | }
52 | },
53 | [zoomImage, promptType, navigate]
54 | );
55 |
56 | useEffect(() => {
57 | const determinePromptType = async () => {
58 | // console.log({ promptType, id, galleryPrompts });
59 | if (!promptType && id) {
60 | setIsLoading(true);
61 | try {
62 | const determinedType = await findPrompt(parseInt(id, 10));
63 | if (determinedType === null) {
64 | navigate("/gallery", { replace: true });
65 | } else {
66 | setPromptType(determinedType);
67 | }
68 | } catch (error) {
69 | console.error("Error determining prompt type:", error);
70 | navigate("/gallery", { replace: true });
71 | } finally {
72 | setIsLoading(false);
73 | }
74 | }
75 | };
76 |
77 | determinePromptType();
78 | }, [id, promptType, findPrompt, navigate, galleryPrompts]);
79 |
80 | useEffect(() => {
81 | window.addEventListener("keydown", handleEscape, { capture: true });
82 | return () => {
83 | window.removeEventListener("keydown", handleEscape, { capture: true });
84 | };
85 | }, [handleEscape]);
86 |
87 | useEffect(() => {
88 | document.documentElement.style.overflow = "hidden";
89 | return () => {
90 | document.documentElement.style.overflow = "";
91 | };
92 | }, []);
93 |
94 | useEffect(() => {
95 | const handleResize = () => {
96 | if (window.innerWidth < 768) {
97 | setZoomImage(true);
98 | } else {
99 | setZoomImage(false);
100 | }
101 | };
102 |
103 | handleResize(); // Set initial state
104 | window.addEventListener("resize", handleResize);
105 | return () => {
106 | window.removeEventListener("resize", handleResize);
107 | };
108 | }, []);
109 |
110 | if (isLoading) {
111 | return (
112 |
115 | );
116 | }
117 |
118 | if (!currentPrompt) {
119 | return null;
120 | }
121 |
122 | return (<>
123 |
138 |
146 | >
147 | );
148 | };
149 |
150 | const PromptWithLayout = WithPrompt(Prompt);
151 | export default PromptWithLayout;
152 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Cross2Icon } from "@radix-ui/react-icons"
3 | import * as ToastPrimitives from "@radix-ui/react-toast"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-zinc-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-zinc-800",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50",
31 | destructive:
32 | "destructive group border-red-500 bg-red-500 text-zinc-50 dark:border-red-900 dark:bg-red-900 dark:text-zinc-50",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/src/components/PromptForm/AspectRatioSlider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo, useEffect } from 'react';
2 | import { Button } from '@/components/ui/button';
3 | import { BidirectionalSlider } from '@/components/ui/bidirectional-slider';
4 | import { usePromptFormStore } from '@/store/promptFormStore';
5 |
6 | interface AspectRatioSliderProps {
7 | baseSize?: number;
8 | className?: string;
9 | }
10 |
11 | const aspectRatios = [
12 | { ratio: '1:2', value: -5 },
13 | { ratio: '9:16', value: -4 },
14 | { ratio: '4:7', value: -3 },
15 | { ratio: '3:5', value: -2 },
16 | { ratio: '17:25', value: -1 },
17 | { ratio: '1:1', value: 0 },
18 | { ratio: '6:5', value: 1 },
19 | { ratio: '4:3', value: 2 },
20 | { ratio: '3:2', value: 3 },
21 | { ratio: '16:9', value: 4 },
22 | { ratio: '2:1', value: 5 },
23 | ];
24 |
25 | const roundToMultipleOf8 = (num: number) => Math.round(num / 8) * 8;
26 |
27 | const AspectRatioSlider: React.FC = ({
28 | baseSize = 1024,
29 | className = '',
30 | }) => {
31 | const { width, height, setWidth, setHeight } = usePromptFormStore();
32 |
33 | const initialRatio = useMemo(() => {
34 | if (width && height) {
35 | const aspectRatio = width / height;
36 | const closestRatio = aspectRatios.reduce((prev, curr) => {
37 | const currAspectRatio = Number(curr.ratio.split(':')[0]) / Number(curr.ratio.split(':')[1]);
38 | return Math.abs(currAspectRatio - aspectRatio) < Math.abs(prev - aspectRatio) ? currAspectRatio : prev;
39 | }, Infinity);
40 | return aspectRatios.find(
41 | (r) => Number(r.ratio.split(':')[0]) / Number(r.ratio.split(':')[1]) === closestRatio
42 | )?.value || 0;
43 | }
44 | return 0;
45 | }, [width, height]);
46 |
47 | const [sliderValue, setSliderValue] = useState(initialRatio);
48 | const selectedRatio = aspectRatios.find((r) => r.value === sliderValue)?.ratio || '1:1';
49 |
50 | const dimensions = useMemo(() => {
51 | const [ratioWidth, ratioHeight] = selectedRatio.split(':').map(Number)
52 | const aspectRatio = ratioWidth / ratioHeight
53 | let calculatedWidth = Math.min(baseSize, roundToMultipleOf8(baseSize * aspectRatio))
54 | let calculatedHeight = Math.min(baseSize, roundToMultipleOf8(baseSize / aspectRatio))
55 |
56 | if (calculatedWidth > baseSize) calculatedWidth = baseSize
57 | if (calculatedHeight > baseSize) calculatedHeight = baseSize
58 |
59 | return { width: calculatedWidth, height: calculatedHeight }
60 | }, [selectedRatio, baseSize])
61 |
62 | useEffect(() => {
63 | if (width && height) {
64 | const aspectRatio = width / height;
65 | const closestRatio = aspectRatios.reduce((prev, curr) => {
66 | const currAspectRatio = Number(curr.ratio.split(':')[0]) / Number(curr.ratio.split(':')[1]);
67 | return Math.abs(currAspectRatio - aspectRatio) < Math.abs(prev - aspectRatio) ? currAspectRatio : prev;
68 | }, Infinity);
69 |
70 | const newSliderValue = aspectRatios.find(
71 | (r) => Number(r.ratio.split(':')[0]) / Number(r.ratio.split(':')[1]) === closestRatio
72 | )?.value;
73 | setSliderValue(newSliderValue || initialRatio);
74 | }
75 | }, [width, height, initialRatio]);
76 |
77 | const handleSliderChange = (value: number) => {
78 | setSliderValue(value);
79 | const selectedAspect = aspectRatios.find((r) => r.value === value)?.ratio || '1:1';
80 | const [ratioWidth, ratioHeight] = selectedAspect.split(':').map(Number);
81 | const aspectRatio = ratioWidth / ratioHeight;
82 |
83 | setWidth(roundToMultipleOf8(ratioHeight > ratioWidth ? Math.round(baseSize * aspectRatio) : baseSize));
84 | setHeight(roundToMultipleOf8(ratioHeight > ratioWidth ? baseSize : Math.round(baseSize / aspectRatio)));
85 | };
86 |
87 | const handleReset = () => handleSliderChange(0);
88 |
89 | return (
90 |
91 |
92 |
Aspect Ratio
93 |
99 |
100 |
101 | {['Portrait', 'Square', 'Landscape'].map((label, index) => {
102 | const value = [-4, 0, 4][index];
103 | return (
104 |
113 | );
114 | })}
115 |
116 |
117 |
118 |
119 | {`${dimensions.width} × ${dimensions.height}px`}
120 | {selectedRatio}
121 |
122 |
123 | );
124 | };
125 |
126 | export default AspectRatioSlider;
127 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | CaretSortIcon,
4 | CheckIcon,
5 | ChevronDownIcon,
6 | ChevronUpIcon,
7 | } from "@radix-ui/react-icons"
8 | import * as SelectPrimitive from "@radix-ui/react-select"
9 |
10 | import { cn } from "@/lib/utils"
11 |
12 | const Select = SelectPrimitive.Root
13 |
14 | const SelectGroup = SelectPrimitive.Group
15 |
16 | const SelectValue = SelectPrimitive.Value
17 |
18 | const SelectTrigger = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, children, ...props }, ref) => (
22 | span]:line-clamp-1 dark:border-zinc-800 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-400 dark:focus:ring-zinc-300",
26 | className
27 | )}
28 | {...props}
29 | >
30 | {children}
31 |
32 |
33 |
34 |
35 | ))
36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
37 |
38 | const SelectScrollUpButton = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 |
51 |
52 | ))
53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
54 |
55 | const SelectScrollDownButton = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
67 |
68 |
69 | ))
70 | SelectScrollDownButton.displayName =
71 | SelectPrimitive.ScrollDownButton.displayName
72 |
73 | const SelectContent = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, children, position = "popper", ...props }, ref) => (
77 |
78 |
89 |
90 |
97 | {children}
98 |
99 |
100 |
101 |
102 | ))
103 | SelectContent.displayName = SelectPrimitive.Content.displayName
104 |
105 | const SelectLabel = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SelectLabel.displayName = SelectPrimitive.Label.displayName
116 |
117 | const SelectItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ))
137 | SelectItem.displayName = SelectPrimitive.Item.displayName
138 |
139 | const SelectSeparator = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef
142 | >(({ className, ...props }, ref) => (
143 |
148 | ))
149 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
150 |
151 | export {
152 | Select,
153 | SelectGroup,
154 | SelectValue,
155 | SelectTrigger,
156 | SelectContent,
157 | SelectLabel,
158 | SelectItem,
159 | SelectSeparator,
160 | SelectScrollUpButton,
161 | SelectScrollDownButton,
162 | }
163 |
--------------------------------------------------------------------------------
/src/components/ImageZoom.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { Loader2 } from "lucide-react";
3 | import { useNavigate } from "react-router-dom";
4 | import { DownloadIcon, CheckIcon, CopyIcon, Heart, Type } from "lucide-react";
5 | import { useCopy } from "@/hooks/useCopy";
6 | import { useLike } from "@/hooks/useLike";
7 | import { useAPrompt } from "@/hooks/useAPrompt";
8 | import { Prompt } from "@/types";
9 | import Slider from "react-slick";
10 | import "slick-carousel/slick/slick.css";
11 | import "slick-carousel/slick/slick-theme.css";
12 |
13 | type ImageZoomProps = {
14 | src: string;
15 | alt: string;
16 | display?: boolean;
17 | setDisplay?: (display: boolean) => void;
18 | prompt: Prompt;
19 | prompts: Prompt[];
20 | };
21 |
22 | export const ImageZoom: React.FC = ({
23 | src,
24 | alt,
25 | display = false,
26 | setDisplay,
27 | prompt,
28 | prompts,
29 | }) => {
30 | const [isLoading, setIsLoading] = useState(true);
31 |
32 | const navigate = useNavigate();
33 |
34 | useEffect(() => {
35 | if (display) {
36 | document.body.style.overflow = 'hidden';
37 | } else {
38 | document.body.style.overflow = 'unset';
39 | }
40 | return () => {
41 | document.body.style.overflow = 'unset';
42 | };
43 | }, [display]);
44 |
45 | const handleImageLoad = () => {
46 | setIsLoading(false);
47 | };
48 |
49 | const handleClose = () => {
50 | if (setDisplay) {
51 | if (window.innerWidth < 768) {
52 | navigate(-1);
53 | } else {
54 | setDisplay(false);
55 | setIsLoading(true);
56 | }
57 | }
58 | };
59 |
60 | const { isLiked: initialIsLiked, handleLike } = useLike(prompt?.liked);
61 | const { isCopied, handleCopy } = useCopy();
62 | const { isPromptUsed, handleUsePrompt } = useAPrompt();
63 |
64 | // Local state to track like status and like count
65 | const [isLiked, setIsLiked] = useState(initialIsLiked);
66 |
67 | const toggleLike = (e: React.MouseEvent) => {
68 | e.stopPropagation();
69 | // Update the like status and like count in the UI
70 | setIsLiked((prev) => !prev);
71 | // Call the hook's handleLike function to handle backend update
72 | handleLike(prompt.id);
73 | };
74 |
75 | // Function to handle downloading the prompt image
76 | const handleDownload = () => {
77 | if (src) {
78 | const element = document.createElement("a");
79 | element.href = src;
80 | element.download = `${prompt?.id || "image"}.png`;
81 | document.body.appendChild(element);
82 | element.click();
83 | document.body.removeChild(element);
84 | }
85 | };
86 |
87 | // reduce prompt to thumbnails
88 | const promptThumbnails = prompts.map((p) => p.images).flat();
89 |
90 | // find index of current image in promptThumbnails
91 | const currentImageIndex = promptThumbnails.indexOf(src);
92 |
93 | const settings = {
94 | className: "center gap-1 h-full w-screen fixed top-0 left-0 z-60 md:hidden",
95 | centerMode: true,
96 | slidesToShow: 9,
97 | initialSlide: currentImageIndex,
98 | infinite: false,
99 | speed: 1,
100 | touchThreshold: 50,
101 | };
102 |
103 | if (!display) return null;
104 |
105 | return (
106 |
107 |
111 |
112 |
113 | {isLoading && (
114 |
115 |
116 |
117 | )}
118 |
119 |
121 | {promptThumbnails.map((image, index) => (
122 | <>
123 |
124 |

131 |
132 | {/* add dummy divs to center last slide */}
133 | {index === promptThumbnails.length - 1 && (
134 | <>
135 |
136 | >
137 | )}
138 | >
139 | ))}
140 | {/* add dummy divs to center last slide */}
141 | {promptThumbnails.length - currentImageIndex < 9 &&
142 | Array.from({ length: Math.max(0, 9 - (promptThumbnails.length - currentImageIndex)) }).map((_, index) => (
143 |
144 | ))}
145 |
146 |
147 |
148 |

157 |
158 |
159 |
{prompt?.id}
160 |
161 |
167 |
178 |
192 |
199 |
200 |
201 |
202 |
203 | {prompt.text && prompt?.text.slice(0, 50)}{prompt.text && prompt?.text.length > 50 && "..."}
204 |
205 |
206 |
207 |
208 |
209 |
210 | );
211 | };
212 |
213 | export default ImageZoom;
214 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
3 | import {
4 | CheckIcon,
5 | ChevronRightIcon,
6 | DotFilledIcon,
7 | } from "@radix-ui/react-icons"
8 |
9 | import { cn } from "@/lib/utils"
10 |
11 | const DropdownMenu = DropdownMenuPrimitive.Root
12 |
13 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
14 |
15 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
16 |
17 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
18 |
19 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
20 |
21 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
22 |
23 | const DropdownMenuSubTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef & {
26 | inset?: boolean
27 | }
28 | >(({ className, inset, children, ...props }, ref) => (
29 |
38 | {children}
39 |
40 |
41 | ))
42 | DropdownMenuSubTrigger.displayName =
43 | DropdownMenuPrimitive.SubTrigger.displayName
44 |
45 | const DropdownMenuSubContent = React.forwardRef<
46 | React.ElementRef,
47 | React.ComponentPropsWithoutRef
48 | >(({ className, ...props }, ref) => (
49 |
57 | ))
58 | DropdownMenuSubContent.displayName =
59 | DropdownMenuPrimitive.SubContent.displayName
60 |
61 | const DropdownMenuContent = React.forwardRef<
62 | React.ElementRef,
63 | React.ComponentPropsWithoutRef
64 | >(({ className, sideOffset = 4, ...props }, ref) => (
65 |
66 |
76 |
77 | ))
78 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
79 |
80 | const DropdownMenuItem = React.forwardRef<
81 | React.ElementRef,
82 | React.ComponentPropsWithoutRef & {
83 | inset?: boolean
84 | }
85 | >(({ className, inset, ...props }, ref) => (
86 |
95 | ))
96 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
97 |
98 | const DropdownMenuCheckboxItem = React.forwardRef<
99 | React.ElementRef,
100 | React.ComponentPropsWithoutRef
101 | >(({ className, children, checked, ...props }, ref) => (
102 |
111 |
112 |
113 |
114 |
115 |
116 | {children}
117 |
118 | ))
119 | DropdownMenuCheckboxItem.displayName =
120 | DropdownMenuPrimitive.CheckboxItem.displayName
121 |
122 | const DropdownMenuRadioItem = React.forwardRef<
123 | React.ElementRef,
124 | React.ComponentPropsWithoutRef
125 | >(({ className, children, ...props }, ref) => (
126 |
134 |
135 |
136 |
137 |
138 |
139 | {children}
140 |
141 | ))
142 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
143 |
144 | const DropdownMenuLabel = React.forwardRef<
145 | React.ElementRef,
146 | React.ComponentPropsWithoutRef & {
147 | inset?: boolean
148 | }
149 | >(({ className, inset, ...props }, ref) => (
150 |
159 | ))
160 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
161 |
162 | const DropdownMenuSeparator = React.forwardRef<
163 | React.ElementRef,
164 | React.ComponentPropsWithoutRef
165 | >(({ className, ...props }, ref) => (
166 |
171 | ))
172 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
173 |
174 | const DropdownMenuShortcut = ({
175 | className,
176 | ...props
177 | }: React.HTMLAttributes) => {
178 | return (
179 |
183 | )
184 | }
185 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
186 |
187 | export {
188 | DropdownMenu,
189 | DropdownMenuTrigger,
190 | DropdownMenuContent,
191 | DropdownMenuItem,
192 | DropdownMenuCheckboxItem,
193 | DropdownMenuRadioItem,
194 | DropdownMenuLabel,
195 | DropdownMenuSeparator,
196 | DropdownMenuShortcut,
197 | DropdownMenuGroup,
198 | DropdownMenuPortal,
199 | DropdownMenuSub,
200 | DropdownMenuSubContent,
201 | DropdownMenuSubTrigger,
202 | DropdownMenuRadioGroup,
203 | }
204 |
--------------------------------------------------------------------------------
/src/components/PromptForm/AddLoraText.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import {
3 | Dialog,
4 | DialogContent,
5 | DialogDescription,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger,
10 | DialogClose,
11 | } from "@/components/ui/dialog";
12 | import { Button } from '@/components/ui/button';
13 | import { Input } from '@/components/ui/input';
14 | import { Card, CardHeader, CardTitle } from '@/components/ui/card';
15 | import { Loader2, Search } from 'lucide-react';
16 | import { getTunes } from '@/api/prompts';
17 | import { usePromptFormStore } from '@/store/promptFormStore';
18 |
19 | interface Tune {
20 | id: string;
21 | title: string;
22 | orig_images: string[];
23 | }
24 |
25 | interface AddLoraTextProps {
26 | onSelect?: (tune: Tune) => void;
27 | onRemove?: (loraText: string) => void;
28 | }
29 |
30 | const ITEMS_PER_PAGE = 12;
31 |
32 | const AddLoraText: React.FC = ({
33 | onSelect,
34 | onRemove,
35 | }) => {
36 | const [isOpen, setIsOpen] = useState(false);
37 | const [tunes, setTunes] = useState([]);
38 | const [loading, setLoading] = useState(false);
39 | const [error, setError] = useState(null);
40 | const [page, setPage] = useState(1);
41 | const [hasMore, setHasMore] = useState(true);
42 | const [searchQuery, setSearchQuery] = useState('');
43 | const [debouncedSearch, setDebouncedSearch] = useState('');
44 |
45 | const {
46 | loraTextList,
47 | setLoraTextList
48 | } = usePromptFormStore();
49 |
50 | // Debounce search query
51 | useEffect(() => {
52 | const timer = setTimeout(() => {
53 | setDebouncedSearch(searchQuery);
54 | setPage(1);
55 | setTunes([]);
56 | }, 300);
57 |
58 | return () => clearTimeout(timer);
59 | }, [searchQuery]);
60 |
61 | const fetchTunes = async () => {
62 | if (loading) return;
63 |
64 | try {
65 | setLoading(true);
66 | setError(null);
67 |
68 | const response = await getTunes(
69 | page,
70 | ITEMS_PER_PAGE,
71 | debouncedSearch
72 | );
73 |
74 | setTunes(prev => page === 1 ? response : [...prev, ...response]);
75 | setHasMore(response.length > 0 && response.length === ITEMS_PER_PAGE);
76 | } catch (err) {
77 | setError('Failed to load tunes. Please try again.');
78 | console.error('Error fetching tunes:', err);
79 | } finally {
80 | setLoading(false);
81 | }
82 | };
83 |
84 | useEffect(() => {
85 | fetchTunes();
86 | }, [page, debouncedSearch]);
87 |
88 | const handleScroll = (e: React.UIEvent) => {
89 | const { scrollTop, clientHeight, scrollHeight } = e.currentTarget;
90 | if (scrollHeight - scrollTop <= clientHeight * 1.5 && hasMore && !loading) {
91 | setPage(prev => prev + 1);
92 | }
93 | };
94 |
95 | const handleSelect = (tune: Tune) => {
96 | setLoraTextList([...loraTextList, ``]);
97 | onSelect?.(tune);
98 | setIsOpen(false);
99 | };
100 |
101 | return (
102 |
103 |
104 |
107 |
108 |
192 |
193 |
194 | {loraTextList.length > 0 && (
195 |
196 | {loraTextList.map((text, index) => (
197 |
201 | {text}
202 |
212 |
213 | ))}
214 |
215 | )}
216 |
217 | );
218 | };
219 |
220 | export default AddLoraText;
--------------------------------------------------------------------------------
/src/components/PromptForm/AdvancedControls.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import AspectRatioSlider from "./AspectRatioSlider";
3 | import ControlNetSelector from "./ControlNetSelector";
4 | import { Switch } from "../ui/switch";
5 | import ColorGradingSelector from "./ColorGradingSelector";
6 | import AddLoraText from "./AddLoraText";
7 | import { usePromptFormStore } from '@/store/promptFormStore';
8 | import { ChevronUp, ChevronDown } from "lucide-react";
9 | import { SelectLabel, Select, SelectItem, SelectTrigger, SelectContent, SelectValue, SelectGroup } from '../ui/select';
10 |
11 | interface RangeInputProps {
12 | label: string;
13 | value: number;
14 | onChange: (value: number) => void;
15 | min?: string;
16 | max?: string;
17 | step?: string;
18 | error?: string | string[] | null;
19 | }
20 |
21 | const RangeInput = ({ label, value, onChange, min = "0.0", max = "1.0", step = "0.01", error }: RangeInputProps) => (
22 |
23 |
24 |
27 | onChange(parseFloat(e.target.value))}
34 | className="col-span-2 w-3/5 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
35 | style={{
36 | WebkitAppearance: 'none',
37 | appearance: 'none',
38 | }}
39 | />
40 |
41 | {error &&
{error}
}
42 |
43 | );
44 |
45 | interface SwitchInputProps {
46 | label: string;
47 | checked: boolean;
48 | onCheckedChange: (checked: boolean) => void;
49 | disabled?: boolean;
50 | }
51 |
52 | const SwitchInput = ({ label, checked, onCheckedChange, disabled = false, error }: SwitchInputProps & { error?: string }) => (
53 |
54 |
55 |
58 |
59 |
60 | {error &&
{error}
}
61 |
62 | );
63 |
64 | export const AdvancedControls = () => {
65 | const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
66 | const { controlNet, setControlNet, colorGrading, setColorGrading, filmGrain, setFilmGrain,
67 | superResolution, setSuperResolution, hiresFix, setHiresFix, inpaintFaces, setInpaintFaces,
68 | faceCorrect, setFaceCorrect, faceSwap, setFaceSwap, denoisingStrength, setDenoisingStrength,
69 | conditioningScale, setConditioningScale, numImages, setNumImages, promptText, setPromptText, error, image, urlImage: imageUrl, controlNetTXT2IMG, setControlNetTXT2IMG, backendVersion, setBackendVersion, steps, setSteps, seed, setSeed
70 | } = usePromptFormStore();
71 |
72 | return (
73 |
74 |
78 |
79 |
80 |
81 |
Advance Settings
82 |
83 |
84 |
{
85 | setPromptText(` ${promptText}`);
86 | }} onRemove={(loraText) => {
87 | setPromptText(promptText.replace(loraText, ""));
88 | }} />
89 |
90 |
91 |
92 |
93 |
96 |
97 |
98 |
99 |
100 | {showAdvancedOptions && (
101 |
102 |
103 |
104 | Backend Version
105 |
106 |
117 | {error?.backend_version && {error.backend_version.join(" ")}
}
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
130 |
setNumImages(parseInt(e.target.value, 10))}
134 | className="col-span-2 border rounded-full h-6 py-1 text-center bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200"
135 | />
136 | {error?.num_images &&
{error.num_images.join(" ")}
}
137 |
138 |
139 |
140 |
143 |
setSteps(parseInt(e.target.value, 10))}
148 | className="col-span-2 border rounded-full h-6 py-1 text-center bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200"
149 | />
150 | {error?.steps &&
{error.steps.join(" ")}
}
151 |
152 |
153 |
154 |
157 |
setSeed(parseInt(e.target.value, 10))}
162 | className="col-span-2 border rounded-full h-6 py-1 text-center bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200"
163 | />
164 | {error?.seed &&
{error.seed.join(" ")}
}
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
177 |
178 |
179 | )}
180 |
181 |
182 |
183 |
184 |
ControlNet/Img2Img
185 |
186 | {(!image && !imageUrl) && (
187 |
Please upload an image to use these controls
188 | )}
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 | );
200 | };
201 |
--------------------------------------------------------------------------------