UI Polish: Refined Lightbox controls, added Cookie Expired popup, and improved mobile filters
This commit is contained in:
parent
58126ca2a1
commit
c25d2664b8
15 changed files with 1261 additions and 918 deletions
141
app/globals.css
141
app/globals.css
|
|
@ -25,50 +25,78 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* Light Mode (from Reference) */
|
/* Light Mode - Slate Refresh */
|
||||||
--background: #F3F4F6;
|
--background: #F8FAFC;
|
||||||
--foreground: #111827;
|
/* Slate-50 */
|
||||||
|
--foreground: #0F172A;
|
||||||
|
/* Slate-900 */
|
||||||
--card: #FFFFFF;
|
--card: #FFFFFF;
|
||||||
--card-foreground: #111827;
|
--card-foreground: #1E293B;
|
||||||
--popover: #FFFFFF;
|
/* Slate-800 */
|
||||||
--popover-foreground: #111827;
|
--popover: rgba(255, 255, 255, 0.8);
|
||||||
--primary: #FFD700;
|
--popover-foreground: #0F172A;
|
||||||
--primary-foreground: #111827;
|
--primary: #7C3AED;
|
||||||
--secondary: #E5E7EB;
|
/* Violet-600 */
|
||||||
--secondary-foreground: #111827;
|
--primary-foreground: #FFFFFF;
|
||||||
--muted: #E5E7EB;
|
--secondary: #E2E8F0;
|
||||||
--muted-foreground: #6B7280;
|
/* Slate-200 */
|
||||||
--accent: #FFD700;
|
--secondary-foreground: #0F172A;
|
||||||
--accent-foreground: #111827;
|
--muted: #F1F5F9;
|
||||||
|
/* Slate-100 */
|
||||||
|
--muted-foreground: #64748B;
|
||||||
|
/* Slate-500 */
|
||||||
|
--accent: #E2E8F0;
|
||||||
|
--accent-foreground: #0F172A;
|
||||||
--destructive: #EF4444;
|
--destructive: #EF4444;
|
||||||
--destructive-foreground: #FEF2F2;
|
--destructive-foreground: #FFFFFF;
|
||||||
--border: #E5E7EB;
|
--border: #E2E8F0;
|
||||||
--input: #E5E7EB;
|
/* Slate-200 */
|
||||||
--ring: #FFD700;
|
--input: #F1F5F9;
|
||||||
--radius: 0.5rem;
|
--ring: rgba(124, 58, 237, 0.4);
|
||||||
|
--radius: 1.25rem;
|
||||||
|
/* Modern rounded corners (20px) */
|
||||||
|
|
||||||
|
/* Spacing & Effects */
|
||||||
|
--header-height: 4rem;
|
||||||
|
--nav-height: 5rem;
|
||||||
|
--shadow-soft: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.07), 0 4px 6px -4px rgb(0 0 0 / 0.07);
|
||||||
|
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* Dark Mode (from Reference) */
|
/* Dark Mode - Deep Navy Slate */
|
||||||
--background: #1F2937;
|
--background: #0F172A;
|
||||||
--foreground: #F9FAFB;
|
/* Slate-900 */
|
||||||
--card: #374151;
|
--foreground: #F8FAFC;
|
||||||
--card-foreground: #F9FAFB;
|
/* Slate-50 */
|
||||||
--popover: #374151;
|
--card: #1E293B;
|
||||||
--popover-foreground: #F9FAFB;
|
/* Slate-800 */
|
||||||
--primary: #FFD700;
|
--card-foreground: #F1F5F9;
|
||||||
--primary-foreground: #111827;
|
--popover: rgba(30, 41, 59, 0.8);
|
||||||
--secondary: #4B5563;
|
--popover-foreground: #F8FAFC;
|
||||||
--secondary-foreground: #F9FAFB;
|
--primary: #8B5CF6;
|
||||||
--muted: #4B5563;
|
/* Violet-500 (brighter for dark) */
|
||||||
--muted-foreground: #9CA3AF;
|
--primary-foreground: #FFFFFF;
|
||||||
--accent: #FFD700;
|
--secondary: #334155;
|
||||||
--accent-foreground: #111827;
|
/* Slate-700 */
|
||||||
--destructive: #EF4444;
|
--secondary-foreground: #F8FAFC;
|
||||||
--destructive-foreground: #FEF2F2;
|
--muted: #334155;
|
||||||
--border: #4B5563;
|
/* Slate-700 */
|
||||||
--input: #4B5563;
|
--muted-foreground: #94A3B8;
|
||||||
--ring: #FFD700;
|
/* Slate-400 */
|
||||||
|
--accent: #334155;
|
||||||
|
--accent-foreground: #F8FAFC;
|
||||||
|
--destructive: #F87171;
|
||||||
|
--destructive-foreground: #FFFFFF;
|
||||||
|
--border: #334155;
|
||||||
|
/* Slate-700 */
|
||||||
|
--input: #0F172A;
|
||||||
|
--ring: rgba(139, 92, 246, 0.5);
|
||||||
|
|
||||||
|
--shadow-soft: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -76,13 +104,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground transition-colors duration-300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
/* Custom Scrollbar - Minimal & Modern */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
|
|
@ -90,10 +119,32 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #374151;
|
background: var(--muted);
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #4B5563;
|
background: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes for Modern Look */
|
||||||
|
.glass-panel {
|
||||||
|
@apply bg-popover backdrop-blur-xl border border-white/10 dark:border-white/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-premium {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-premium-lg {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
|
@ -16,11 +17,13 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={inter.className} suppressHydrationWarning>
|
<body className={inter.className} suppressHydrationWarning>
|
||||||
|
<ThemeProvider defaultTheme="system" storageKey="kv-pix-theme">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{children}
|
{children}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
28
app/page.tsx
28
app/page.tsx
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
import { Gallery } from "@/components/Gallery";
|
import { Gallery } from "@/components/Gallery";
|
||||||
|
|
@ -9,9 +8,8 @@ import { PromptHero } from "@/components/PromptHero";
|
||||||
import { Settings } from "@/components/Settings";
|
import { Settings } from "@/components/Settings";
|
||||||
import { PromptLibrary } from "@/components/PromptLibrary";
|
import { PromptLibrary } from "@/components/PromptLibrary";
|
||||||
import { UploadHistory } from "@/components/UploadHistory";
|
import { UploadHistory } from "@/components/UploadHistory";
|
||||||
|
|
||||||
import { CookieExpiredDialog } from "@/components/CookieExpiredDialog";
|
import { CookieExpiredDialog } from "@/components/CookieExpiredDialog";
|
||||||
|
import { BottomNav } from "@/components/BottomNav";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { currentView, setCurrentView, loadGallery } = useStore();
|
const { currentView, setCurrentView, loadGallery } = useStore();
|
||||||
|
|
@ -20,17 +18,29 @@ export default function Home() {
|
||||||
loadGallery();
|
loadGallery();
|
||||||
}, [loadGallery]);
|
}, [loadGallery]);
|
||||||
|
|
||||||
|
const handleTabChange = (tab: 'create' | 'library' | 'uploads' | 'settings') => {
|
||||||
|
if (tab === 'create') setCurrentView('gallery');
|
||||||
|
else if (tab === 'uploads') setCurrentView('history');
|
||||||
|
else setCurrentView(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveTab = () => {
|
||||||
|
if (currentView === 'gallery') return 'create';
|
||||||
|
if (currentView === 'history') return 'uploads';
|
||||||
|
return currentView as 'create' | 'library' | 'uploads' | 'settings';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden font-sans flex-col">
|
<div className="flex h-[100dvh] w-full bg-background text-foreground overflow-hidden font-sans flex-col relative">
|
||||||
{/* Top Navbar */}
|
{/* Top Navbar - Mobile Header */}
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 relative flex flex-col h-full w-full overflow-hidden mt-16">
|
<main className="flex-1 relative flex flex-col w-full overflow-hidden">
|
||||||
|
|
||||||
{/* Scrollable Container */}
|
{/* Scrollable Container */}
|
||||||
<div className="flex-1 overflow-y-auto w-full scroll-smooth">
|
<div className="flex-1 overflow-y-auto w-full scroll-smooth no-scrollbar pt-16 pb-24 md:pb-0">
|
||||||
<div className="min-h-full w-full max-w-[1600px] mx-auto p-4 md:p-6 pb-20">
|
<div className="w-full max-w-lg md:max-w-7xl mx-auto p-4 md:p-6 min-h-full">
|
||||||
|
|
||||||
{/* Always show Hero on Create View */}
|
{/* Always show Hero on Create View */}
|
||||||
{currentView === 'gallery' && (
|
{currentView === 'gallery' && (
|
||||||
|
|
@ -52,6 +62,8 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Bottom Navigation (Mobile & Desktop App-like) */}
|
||||||
|
<BottomNav currentTab={getActiveTab()} onTabChange={handleTabChange} />
|
||||||
|
|
||||||
<CookieExpiredDialog />
|
<CookieExpiredDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
100
components/BottomNav.tsx
Normal file
100
components/BottomNav.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Sparkles, LayoutGrid, Clock, Settings, Zap } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface BottomNavProps {
|
||||||
|
currentTab?: 'create' | 'library' | 'uploads' | 'settings';
|
||||||
|
onTabChange?: (tab: 'create' | 'library' | 'uploads' | 'settings') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNav({ currentTab = 'create', onTabChange }: BottomNavProps) {
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 glass-panel border-t border-border px-8 py-3 pb-8 z-[100] shadow-premium">
|
||||||
|
<div className="flex justify-between items-center max-w-lg mx-auto">
|
||||||
|
|
||||||
|
{/* Create Tab (Highlighted) */}
|
||||||
|
<button
|
||||||
|
onClick={() => onTabChange?.('create')}
|
||||||
|
className="flex flex-col items-center space-y-1 relative group"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className={cn(
|
||||||
|
"w-12 h-10 rounded-2xl flex items-center justify-center transition-all duration-300",
|
||||||
|
currentTab === 'create'
|
||||||
|
? "bg-primary shadow-lg shadow-primary/30"
|
||||||
|
: "bg-muted/50 hover:bg-muted"
|
||||||
|
)}>
|
||||||
|
<Sparkles className={cn(
|
||||||
|
"h-5 w-5 transition-colors",
|
||||||
|
currentTab === 'create' ? "text-white" : "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
{currentTab === 'create' && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="nav-glow"
|
||||||
|
className="absolute inset-0 bg-primary/20 blur-lg -z-10 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-bold tracking-tight transition-colors",
|
||||||
|
currentTab === 'create' ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}>Create</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prompt Library */}
|
||||||
|
<button
|
||||||
|
onClick={() => onTabChange?.('library')}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center space-y-1 transition-all group",
|
||||||
|
currentTab === 'library' ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"p-2 rounded-xl transition-all",
|
||||||
|
currentTab === 'library' ? "bg-primary/10" : "group-hover:bg-muted/50"
|
||||||
|
)}>
|
||||||
|
<LayoutGrid className={cn("h-6 w-6", currentTab === 'library' ? "text-primary" : "")} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-[10px] font-semibold", currentTab === 'library' ? "text-primary" : "")}>Library</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Uploads */}
|
||||||
|
<button
|
||||||
|
onClick={() => onTabChange?.('uploads')}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center space-y-1 transition-all group",
|
||||||
|
currentTab === 'uploads' ? "text-primaryScale-500" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"p-2 rounded-xl transition-all",
|
||||||
|
currentTab === 'uploads' ? "bg-primary/10" : "group-hover:bg-muted/50"
|
||||||
|
)}>
|
||||||
|
<Clock className={cn("h-6 w-6", currentTab === 'uploads' ? "text-primary" : "")} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-[10px] font-semibold", currentTab === 'uploads' ? "text-primary" : "")}>Uploads</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<button
|
||||||
|
onClick={() => onTabChange?.('settings')}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center space-y-1 transition-all group",
|
||||||
|
currentTab === 'settings' ? "text-primaryScale-500" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"p-2 rounded-xl transition-all",
|
||||||
|
currentTab === 'settings' ? "bg-primary/10" : "group-hover:bg-muted/50"
|
||||||
|
)}>
|
||||||
|
<Settings className={cn("h-6 w-6", currentTab === 'settings' ? "text-primary" : "")} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-[10px] font-semibold", currentTab === 'settings' ? "text-primary" : "")}>Settings</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,37 +26,43 @@ export function CookieExpiredDialog() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-300">
|
||||||
<div className="relative w-full max-w-md bg-[#18181B] border border-white/10 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-200 overflow-hidden">
|
<div className="relative w-full max-w-[400px] bg-[#121214] border border-white/5 rounded-[2.5rem] shadow-[0_0_50px_-12px_rgba(0,0,0,0.5)] animate-in zoom-in-95 duration-300 overflow-hidden">
|
||||||
|
|
||||||
{/* Decorative header background */}
|
{/* Top Glow/Gradient */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-br from-amber-500/10 to-red-500/10 pointer-events-none" />
|
<div className="absolute top-0 left-0 right-0 h-48 bg-gradient-to-b from-amber-500/20 via-transparent to-transparent pointer-events-none" />
|
||||||
|
|
||||||
<div className="relative p-6 px-8 flex flex-col items-center text-center">
|
<div className="relative p-8 flex flex-col items-center text-center">
|
||||||
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCookieExpired(false)}
|
onClick={() => setShowCookieExpired(false)}
|
||||||
className="absolute top-4 right-4 p-2 text-white/40 hover:text-white rounded-full hover:bg-white/5 transition-colors"
|
className="absolute top-6 right-6 p-2 text-white/20 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="h-16 w-16 mb-6 rounded-full bg-amber-500/10 flex items-center justify-center ring-1 ring-amber-500/20 shadow-lg shadow-amber-900/20">
|
{/* Cookie Icon Container */}
|
||||||
<Cookie className="h-8 w-8 text-amber-500" />
|
<div className="relative mt-4 mb-8">
|
||||||
|
{/* Glow effect */}
|
||||||
|
<div className="absolute inset-0 bg-amber-500/20 blur-2xl rounded-full" />
|
||||||
|
<div className="relative h-20 w-20 rounded-full bg-[#1A1A1D] border-2 border-amber-500/30 flex items-center justify-center shadow-[inset_0_0_20px_rgba(245,158,11,0.1)]">
|
||||||
|
<Cookie className="h-10 w-10 text-amber-500" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-xl font-bold text-white mb-2">Cookies Expired</h2>
|
<h2 className="text-2xl font-black text-white mb-4 tracking-tight">Cookies Expired</h2>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm mb-6 leading-relaxed">
|
<p className="text-[#A1A1AA] text-[15px] mb-8 leading-relaxed max-w-[280px]">
|
||||||
Your <span className="text-white font-medium">{providerName}</span> session has timed out.
|
Your <span className="text-white font-bold">{providerName}</span> session has timed out.
|
||||||
To continue generating images, please refresh your cookies.
|
To continue generating images, please refresh your cookies.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleFixIssues}
|
onClick={handleFixIssues}
|
||||||
className="w-full py-3 px-4 bg-primary text-primary-foreground font-semibold rounded-xl hover:bg-primary/90 transition-all flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
|
className="w-full py-4 px-6 bg-[#7C3AED] hover:bg-[#6D28D9] text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-3 shadow-[0_10px_20px_-10px_rgba(124,58,237,0.5)] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-5 w-5" />
|
||||||
Update Settings
|
Update Settings
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -64,9 +70,9 @@ export function CookieExpiredDialog() {
|
||||||
href={providerUrl}
|
href={providerUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-full py-3 px-4 bg-white/5 hover:bg-white/10 text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2 border border-white/5"
|
className="w-full py-4 px-6 bg-[#27272A] hover:bg-[#3F3F46] text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-3 border border-white/5 active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-5 w-5" />
|
||||||
Open {providerName}
|
Open {providerName}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,15 @@ export function Gallery() {
|
||||||
const [videoPromptValue, setVideoPromptValue] = React.useState('');
|
const [videoPromptValue, setVideoPromptValue] = React.useState('');
|
||||||
const [useSourceImage, setUseSourceImage] = React.useState(true);
|
const [useSourceImage, setUseSourceImage] = React.useState(true);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
|
||||||
|
const [showControls, setShowControls] = React.useState(true);
|
||||||
|
const [isMobile, setIsMobile] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener('resize', checkMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (selectedIndex !== null && gallery[selectedIndex]) {
|
if (selectedIndex !== null && gallery[selectedIndex]) {
|
||||||
|
|
@ -389,25 +398,28 @@ export function Gallery() {
|
||||||
return (
|
return (
|
||||||
<div className="pb-32">
|
<div className="pb-32">
|
||||||
{/* Header with Clear All */}
|
{/* Header with Clear All */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-8 px-2 relative z-10">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">{gallery.length} Generated Images</h2>
|
<h2 className="text-xl font-extrabold text-foreground tracking-tight">{gallery.length} Creations</h2>
|
||||||
|
<p className="text-[10px] uppercase font-bold tracking-widest text-muted-foreground mt-0.5">Your library of generated images</p>
|
||||||
</div>
|
</div>
|
||||||
|
{gallery.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
className="text-destructive hover:bg-destructive/10 text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all px-4 py-2 rounded-xl border border-destructive/20"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<span>Clear All</span>
|
<span>Reset</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Videos Section - Show generated videos */}
|
{/* Videos Section - Show generated videos */}
|
||||||
{videos.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4 px-1">
|
||||||
<Film className="h-5 w-5 text-blue-500" />
|
<Film className="h-5 w-5 text-blue-500" />
|
||||||
<h3 className="text-lg font-semibold">{videos.length} Generated Video{videos.length > 1 ? 's' : ''}</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{videos.length} Generated Video{videos.length > 1 ? 's' : ''}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{videos.map((vid) => (
|
{videos.map((vid) => (
|
||||||
|
|
@ -416,7 +428,7 @@ export function Gallery() {
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="group relative aspect-video rounded-xl overflow-hidden bg-black border border-white/10 shadow-lg"
|
className="group relative aspect-video rounded-2xl overflow-hidden bg-black border border-gray-200 dark:border-gray-800 shadow-sm"
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
src={vid.url}
|
src={vid.url}
|
||||||
|
|
@ -427,13 +439,13 @@ export function Gallery() {
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeVideo(vid.id)}
|
onClick={() => removeVideo(vid.id)}
|
||||||
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-destructive/80 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
|
className="absolute top-3 right-3 w-8 h-8 bg-black/50 backdrop-blur-md rounded-full flex items-center justify-center text-white hover:bg-black/70 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
title="Delete video"
|
title="Delete video"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
<p className="text-white text-xs line-clamp-1">{vid.prompt}</p>
|
<p className="text-white text-xs line-clamp-1 font-medium">{vid.prompt}</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -442,15 +454,12 @@ export function Gallery() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gallery Grid */}
|
{/* Gallery Grid */}
|
||||||
<div className="columns-2 sm:columns-2 md:columns-3 lg:columns-4 gap-3 md:gap-4 space-y-3 md:space-y-4">
|
<div className="columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4">
|
||||||
{/* Skeleton Loading State */}
|
{/* Skeleton Loading State */}
|
||||||
{isGenerating && (
|
{isGenerating && (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: settings.imageCount || 4 }).map((_, i) => (
|
{Array.from({ length: settings.imageCount || 4 }).map((_, i) => (
|
||||||
<div key={`skeleton-${i}`} className="break-inside-avoid rounded-xl overflow-hidden bg-white/5 border border-white/5 shadow-sm mb-4 relative aspect-[2/3] animate-pulse">
|
<div key={`skeleton-${i}`} className="break-inside-avoid rounded-2xl overflow-hidden bg-gray-100 dark:bg-[#1a2332] border border-gray-200 dark:border-gray-800 shadow-sm mb-4 relative aspect-[2/3] animate-pulse">
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-white/10 to-transparent" />
|
|
||||||
<div className="absolute bottom-4 left-4 right-4 h-4 bg-white/20 rounded w-3/4" />
|
|
||||||
<div className="absolute top-2 left-2 w-12 h-4 bg-white/20 rounded" />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -460,45 +469,44 @@ export function Gallery() {
|
||||||
{gallery.map((img, i) => (
|
{gallery.map((img, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={img.id || `video-${i}`}
|
key={img.id || `video-${i}`}
|
||||||
|
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm"
|
className="group relative break-inside-avoid rounded-[2rem] overflow-hidden bg-card shadow-soft hover:shadow-premium transition-all duration-500 cursor-pointer border border-border/50"
|
||||||
|
onClick={() => setSelectedIndex(i)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getImageSrc(img.data)}
|
src={getImageSrc(img.data)}
|
||||||
alt={img.prompt}
|
alt={img.prompt}
|
||||||
className="w-full h-auto object-cover transition-transform group-hover:scale-105 cursor-pointer"
|
className="w-full h-auto object-cover group-hover:scale-110 transition-transform duration-700"
|
||||||
onClick={() => setSelectedIndex(i)}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Provider Tag */}
|
{/* Overlay Gradient */}
|
||||||
{img.provider && (
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-40 group-hover:opacity-60 transition-opacity duration-500" />
|
||||||
<div className={cn(
|
|
||||||
"absolute top-2 left-2 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider text-white shadow-sm backdrop-blur-md border border-white/10 z-10",
|
|
||||||
img.provider === 'meta' ? "bg-blue-500/80" :
|
|
||||||
"bg-amber-500/80"
|
|
||||||
)}>
|
|
||||||
{img.provider}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete button - Top right */}
|
{/* Provider Badge */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-4 left-4 text-white text-[9px] font-black tracking-widest px-2.5 py-1 rounded-lg shadow-lg backdrop-blur-xl border border-white/20",
|
||||||
|
img.provider === 'meta' ? "bg-primary/80" : "bg-secondary/80"
|
||||||
|
)}>
|
||||||
|
{img.provider === 'meta' ? 'META AI' : 'WHISK'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button (Floating) */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (img.id) removeFromGallery(img.id); }}
|
onClick={(e) => { e.stopPropagation(); if (img.id) removeFromGallery(img.id); }}
|
||||||
|
className="absolute top-4 right-4 w-9 h-9 bg-black/40 backdrop-blur-xl border border-white/10 rounded-2xl flex items-center justify-center text-white hover:bg-destructive transition-all md:opacity-0 md:group-hover:opacity-100 active:scale-90"
|
||||||
className="absolute top-2 right-2 p-2 md:p-1.5 bg-black/60 hover:bg-destructive/80 rounded-full text-white opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
|
||||||
title="Delete"
|
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Hover Overlay - Simplified: just show prompt */}
|
{/* Caption - Glass Style */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity flex flex-col justify-end p-2 md:p-3 pointer-events-none">
|
<div className="absolute bottom-4 left-4 right-4 p-3 glass-panel rounded-2xl border-white/10 md:opacity-0 md:group-hover:opacity-100 translate-y-2 md:group-hover:translate-y-0 transition-all duration-500">
|
||||||
<p className="text-white text-xs line-clamp-2">{img.prompt}</p>
|
<p className="text-white text-[10px] font-bold line-clamp-2 leading-tight tracking-tight">
|
||||||
|
{img.prompt}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -512,54 +520,118 @@ export function Gallery() {
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 backdrop-blur-md p-4 md:p-6"
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-background/95 dark:bg-black/95 backdrop-blur-3xl"
|
||||||
onClick={() => setSelectedIndex(null)}
|
onClick={() => setSelectedIndex(null)}
|
||||||
>
|
>
|
||||||
{/* Close Button */}
|
{/* Top Controls Bar */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-20 flex items-center justify-between px-6 z-[120] pointer-events-none">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
|
onClick={() => setShowControls(!showControls)}
|
||||||
|
className={cn(
|
||||||
|
"p-3 rounded-full transition-all border shadow-xl backdrop-blur-md active:scale-95",
|
||||||
|
showControls
|
||||||
|
? "bg-primary text-white border-primary/50"
|
||||||
|
: "bg-black/60 text-white border-white/20 hover:bg-black/80"
|
||||||
|
)}
|
||||||
|
title={showControls ? "Hide Controls" : "Show Controls"}
|
||||||
|
>
|
||||||
|
<Sparkles className={cn("h-5 w-5", showControls ? "animate-pulse" : "")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="p-3 bg-background/50 hover:bg-background rounded-full text-foreground transition-colors border border-border shadow-xl backdrop-blur-md pointer-events-auto"
|
||||||
onClick={() => setSelectedIndex(null)}
|
onClick={() => setSelectedIndex(null)}
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
{selectedIndex > 0 && (
|
|
||||||
<button
|
|
||||||
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-6 w-6 md:h-8 md:w-8" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{selectedIndex < gallery.length - 1 && (
|
|
||||||
<button
|
|
||||||
className="absolute left-[calc(50%-2rem)] md:left-[calc(50%+8rem)] top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-6 w-6 md:h-8 md:w-8" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Split Panel Container */}
|
{/* Split Panel Container */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.95, opacity: 0 }}
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.95, opacity: 0 }}
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
className="relative w-full max-w-6xl max-h-[90vh] flex flex-col md:flex-row gap-4 md:gap-6 overflow-hidden"
|
className="relative w-full h-full flex flex-col md:flex-row gap-0 overflow-hidden"
|
||||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
onPanEnd={(_, info) => {
|
||||||
|
// Swipe Up specific (check velocity or offset)
|
||||||
|
// Negative Y is UP.
|
||||||
|
if (!showControls && info.offset.y < -50) {
|
||||||
|
setShowControls(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Left: Image */}
|
{/* Left: Image Container (Full size) */}
|
||||||
<div className="flex-1 flex items-center justify-center min-h-0">
|
<div className="flex-1 flex items-center justify-center min-h-0 relative group/arrows p-4 md:p-12">
|
||||||
<img
|
<motion.img
|
||||||
|
layout
|
||||||
src={getImageSrc(selectedImage.data)}
|
src={getImageSrc(selectedImage.data)}
|
||||||
alt={selectedImage.prompt}
|
alt={selectedImage.prompt}
|
||||||
className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
|
className={cn(
|
||||||
|
"max-w-full max-h-full object-contain rounded-2xl shadow-2xl transition-all duration-500",
|
||||||
|
showControls ? "md:scale-[0.9] scale-[0.85] translate-y-[-10%] md:translate-y-0" : "scale-100"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Repositioned Arrows (relative to image container) */}
|
||||||
|
{selectedIndex > 0 && (
|
||||||
|
<button
|
||||||
|
className="absolute left-6 top-1/2 -translate-y-1/2 p-4 bg-background/30 hover:bg-background/50 rounded-full text-foreground transition-all z-10 border border-border/20 shadow-2xl backdrop-blur-md active:scale-90"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-8 w-8" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedIndex < gallery.length - 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute right-6 top-1/2 -translate-y-1/2 p-4 bg-background/30 hover:bg-background/50 rounded-full text-foreground transition-all z-10 border border-border/20 shadow-2xl backdrop-blur-md active:scale-90"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-8 w-8" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Swipe Up Hint Handle (Only when controls are hidden) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!showControls && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 10 }}
|
||||||
|
className="absolute bottom-6 left-1/2 -translate-x-1/2 flex flex-col items-center gap-1 z-20 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-1.5 bg-white/30 rounded-full backdrop-blur-sm shadow-sm" />
|
||||||
|
{/* Optional: Add a chevron up or just the line. User asked for "hint handle", implying likely just the pill shape or similar to iOS home bar but specifically for swiping up content */}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Controls Panel */}
|
{/* Right: Controls Panel (Retractable) */}
|
||||||
<div className="w-full md:w-80 lg:w-96 flex flex-col gap-4 bg-white/5 rounded-xl p-4 md:p-5 border border-white/10 backdrop-blur-sm max-h-[40vh] md:max-h-[85vh] overflow-y-auto">
|
<AnimatePresence>
|
||||||
|
{showControls && (
|
||||||
|
<motion.div
|
||||||
|
initial={isMobile ? { y: "100%", opacity: 0 } : { x: "100%", opacity: 0 }}
|
||||||
|
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||||
|
exit={isMobile ? { y: "100%", opacity: 0 } : { x: "100%", opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 30, stiffness: 300, mass: 0.8 }}
|
||||||
|
drag={isMobile ? "y" : false}
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
if (isMobile && info.offset.y > 100) {
|
||||||
|
setShowControls(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full md:w-[400px] flex flex-col bg-card/80 dark:bg-black/80 border-l border-border backdrop-blur-2xl shadow-left-premium z-[130] absolute bottom-0 inset-x-0 md:relative md:inset-auto md:h-full h-[70vh] rounded-t-[3rem] md:rounded-none overflow-hidden touch-none"
|
||||||
|
>
|
||||||
|
{/* Drag Handle (Mobile) */}
|
||||||
|
<div className="h-1.5 w-12 bg-zinc-500/40 group-hover:bg-zinc-500/60 rounded-full mx-auto mt-4 mb-2 md:hidden cursor-grab active:cursor-grabbing transition-colors" />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-6 p-6 md:p-8 overflow-y-auto custom-scrollbar pb-32 md:pb-8">
|
||||||
{/* Provider Badge */}
|
{/* Provider Badge */}
|
||||||
{selectedImage.provider && (
|
{selectedImage.provider && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|
@ -574,7 +646,7 @@ export function Gallery() {
|
||||||
{/* Prompt Section (Editable) */}
|
{/* Prompt Section (Editable) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Prompt</h3>
|
<h3 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/70">Original Prompt</h3>
|
||||||
{editPromptValue !== selectedImage.prompt && (
|
{editPromptValue !== selectedImage.prompt && (
|
||||||
<span className="text-[10px] text-amber-400 font-medium animate-pulse">Modified</span>
|
<span className="text-[10px] text-amber-400 font-medium animate-pulse">Modified</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -582,14 +654,14 @@ export function Gallery() {
|
||||||
<textarea
|
<textarea
|
||||||
value={editPromptValue}
|
value={editPromptValue}
|
||||||
onChange={(e) => setEditPromptValue(e.target.value)}
|
onChange={(e) => setEditPromptValue(e.target.value)}
|
||||||
className="w-full h-24 bg-black/20 border border-white/10 rounded-lg p-3 text-sm text-white resize-none focus:ring-1 focus:ring-amber-500/30 outline-none placeholder:text-white/20"
|
className="w-full h-24 bg-background/50 border border-border/50 rounded-2xl p-4 text-xs text-foreground resize-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 outline-none transition-all placeholder:text-muted-foreground/30 font-medium"
|
||||||
placeholder="Enter prompt..."
|
placeholder="Enter prompt..."
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(!selectedImage.provider || selectedImage.provider === 'whisk' || selectedImage.provider === 'meta') && (
|
{(!selectedImage.provider || selectedImage.provider === 'whisk' || selectedImage.provider === 'meta') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal({ ...selectedImage, prompt: editPromptValue })}
|
onClick={() => openEditModal({ ...selectedImage, prompt: editPromptValue })}
|
||||||
className="flex-1 py-2 bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 rounded-lg text-xs font-medium text-white transition-all flex items-center justify-center gap-2"
|
className="flex-1 py-2.5 bg-primary text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-primary/90 transition-all flex items-center justify-center gap-2 active:scale-95 shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
<Wand2 className="h-3 w-3" />
|
<Wand2 className="h-3 w-3" />
|
||||||
<span>Remix</span>
|
<span>Remix</span>
|
||||||
|
|
@ -601,7 +673,7 @@ export function Gallery() {
|
||||||
alert("Prompt copied!");
|
alert("Prompt copied!");
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors",
|
"px-3 py-2.5 bg-muted hover:bg-muted/80 rounded-xl text-foreground transition-all border border-border/50 active:scale-95",
|
||||||
(!selectedImage.provider || selectedImage.provider === 'whisk') ? "" : "flex-1"
|
(!selectedImage.provider || selectedImage.provider === 'whisk') ? "" : "flex-1"
|
||||||
)}
|
)}
|
||||||
title="Copy Prompt"
|
title="Copy Prompt"
|
||||||
|
|
@ -617,7 +689,7 @@ export function Gallery() {
|
||||||
{/* Video Generation Section */}
|
{/* Video Generation Section */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider flex items-center gap-2">
|
<h3 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/70 flex items-center gap-2">
|
||||||
<Film className="h-3 w-3" />
|
<Film className="h-3 w-3" />
|
||||||
Animate
|
Animate
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -625,8 +697,8 @@ export function Gallery() {
|
||||||
<textarea
|
<textarea
|
||||||
value={videoPromptValue}
|
value={videoPromptValue}
|
||||||
onChange={(e) => setVideoPromptValue(e.target.value)}
|
onChange={(e) => setVideoPromptValue(e.target.value)}
|
||||||
placeholder="Describe movement (e.g. natural movement, zoom in)..."
|
placeholder="Describe movement..."
|
||||||
className="w-full h-20 bg-black/20 border border-white/10 rounded-lg p-3 text-sm text-white resize-none focus:ring-1 focus:ring-purple-500/50 outline-none placeholder:text-white/30"
|
className="w-full h-20 bg-background/50 border border-border/50 rounded-2xl p-4 text-xs text-foreground resize-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 outline-none transition-all placeholder:text-muted-foreground/30 font-medium"
|
||||||
/>
|
/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const isGenerating = isGeneratingMetaVideo || isGeneratingWhiskVideo;
|
const isGenerating = isGeneratingMetaVideo || isGeneratingWhiskVideo;
|
||||||
|
|
@ -645,8 +717,8 @@ export function Gallery() {
|
||||||
isGenerating
|
isGenerating
|
||||||
? "bg-gray-600 cursor-wait"
|
? "bg-gray-600 cursor-wait"
|
||||||
: !canGenerate
|
: !canGenerate
|
||||||
? "bg-gray-600/50 cursor-not-allowed opacity-60"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50 border border-border/50"
|
||||||
: "bg-purple-600 hover:bg-purple-500"
|
: "bg-blue-600 hover:bg-blue-500 shadow-lg shadow-blue-500/20"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
|
|
@ -680,13 +752,13 @@ export function Gallery() {
|
||||||
|
|
||||||
{/* Other Actions */}
|
{/* Other Actions */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Other Actions</h3>
|
<h3 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/70">Library Actions</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<a
|
<a
|
||||||
href={getImageSrc(selectedImage.data)}
|
href={getImageSrc(selectedImage.data)}
|
||||||
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
|
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
|
||||||
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-white/80 text-xs font-medium transition-colors"
|
className="flex items-center justify-center gap-2 px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-xl text-foreground text-[10px] font-black uppercase tracking-widest transition-all border border-border/50"
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
<span>Download</span>
|
<span>Download</span>
|
||||||
|
|
@ -697,7 +769,7 @@ export function Gallery() {
|
||||||
setPrompt(selectedImage.prompt);
|
setPrompt(selectedImage.prompt);
|
||||||
setSelectedIndex(null);
|
setSelectedIndex(null);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-white/80 text-xs font-medium transition-colors"
|
className="flex items-center justify-center gap-2 px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-xl text-foreground text-[10px] font-black uppercase tracking-widest transition-all border border-border/50"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
<span>Use Prompt</span>
|
<span>Use Prompt</span>
|
||||||
|
|
@ -711,21 +783,17 @@ export function Gallery() {
|
||||||
setSelectedIndex(null);
|
setSelectedIndex(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center gap-2 w-full px-3 py-2 bg-red-500/10 hover:bg-red-500/20 rounded-lg text-red-400 text-xs font-medium transition-colors border border-red-500/20"
|
className="flex items-center justify-center gap-2 w-full px-3 py-2.5 bg-destructive/10 hover:bg-destructive/20 rounded-xl text-destructive text-[10px] font-black uppercase tracking-widest transition-all border border-destructive/20 active:scale-95"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<span>Delete Image</span>
|
<span>Delete Image</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Info */}
|
</div>
|
||||||
<div className="mt-auto pt-3 border-t border-white/10 text-xs text-white/40 space-y-1">
|
</motion.div>
|
||||||
{selectedImage.aspectRatio && (
|
|
||||||
<p>Aspect Ratio: {selectedImage.aspectRatio}</p>
|
|
||||||
)}
|
)}
|
||||||
<p>Image {selectedIndex + 1} of {gallery.length}</p>
|
</AnimatePresence>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,88 +9,109 @@ export function MobileCookieInstructions() {
|
||||||
const [platform, setPlatform] = useState<'desktop' | 'android' | 'ios'>('desktop');
|
const [platform, setPlatform] = useState<'desktop' | 'android' | 'ios'>('desktop');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-white/10 rounded-xl bg-white/5 overflow-hidden">
|
<div className="border border-border/50 rounded-xl bg-card overflow-hidden shadow-soft">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-white/5 transition-colors"
|
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Smartphone className="h-5 w-5 text-purple-400" />
|
<Smartphone className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium text-sm text-white/90">How to get cookies on Mobile?</span>
|
<span className="font-bold text-sm text-foreground">How to get cookies on Mobile?</span>
|
||||||
</div>
|
</div>
|
||||||
{open ? <ChevronDown className="h-4 w-4 text-white/50" /> : <ChevronRight className="h-4 w-4 text-white/50" />}
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="p-4 pt-0 border-t border-white/5 bg-black/20">
|
<div className="p-6 border-t border-border/10 bg-muted/20 space-y-6">
|
||||||
<div className="flex gap-2 mb-4 overflow-x-auto pb-2 scrollbar-none">
|
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlatform('desktop')}
|
onClick={() => setPlatform('desktop')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors",
|
"flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold whitespace-nowrap transition-all active:scale-95",
|
||||||
platform === 'desktop' ? "bg-purple-500/20 text-purple-200 border border-purple-500/30" : "bg-white/5 text-white/60 hover:bg-white/10"
|
platform === 'desktop' ? "bg-primary/20 text-primary border border-primary/30 shadow-lg shadow-primary/10" : "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Monitor className="h-3 w-3" />
|
<Monitor className="h-3.5 w-3.5" />
|
||||||
Desktop Sync (Best)
|
Desktop Sync (Best)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlatform('android')}
|
onClick={() => setPlatform('android')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors",
|
"flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold whitespace-nowrap transition-all active:scale-95",
|
||||||
platform === 'android' ? "bg-green-500/20 text-green-200 border border-green-500/30" : "bg-white/5 text-white/60 hover:bg-white/10"
|
platform === 'android' ? "bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 shadow-lg shadow-green-500/10" : "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Smartphone className="h-3 w-3" />
|
<Smartphone className="h-3.5 w-3.5" />
|
||||||
Android
|
Android
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlatform('ios')}
|
onClick={() => setPlatform('ios')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors",
|
"flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold whitespace-nowrap transition-all active:scale-95",
|
||||||
platform === 'ios' ? "bg-blue-500/20 text-blue-200 border border-blue-500/30" : "bg-white/5 text-white/60 hover:bg-white/10"
|
platform === 'ios' ? "bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/30 shadow-lg shadow-blue-500/10" : "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Smartphone className="h-3 w-3" />
|
<Smartphone className="h-3.5 w-3.5" />
|
||||||
iPhone (iOS)
|
iPhone (iOS)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 text-sm text-white/80">
|
<div className="space-y-4 max-w-2xl">
|
||||||
{platform === 'desktop' && (
|
{platform === 'desktop' && (
|
||||||
<div className="space-y-2 animate-in fade-in slide-in-from-left-2 duration-300">
|
<div className="space-y-4 animate-in fade-in slide-in-from-left-4 duration-500">
|
||||||
<p className="text-xs text-white/60">Getting cookies on mobile is hard. The easiest way is to do it on a PC and send it to yourself.</p>
|
<p className="text-sm font-medium text-muted-foreground leading-relaxed">Getting cookies on mobile is hard. The easiest way is to do it on a PC and send it to yourself.</p>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-xs">
|
<ol className="space-y-3">
|
||||||
<li>Install <strong>Cookie-Editor</strong> extension on your PC browser (Chrome/Edge).</li>
|
{[
|
||||||
<li>Go to <code>labs.google</code> (Whisk) or <code>facebook.com</code> (Meta) and login.</li>
|
{ step: 1, text: <>Install <strong className="text-foreground">Cookie-Editor</strong> extension on your PC browser (Chrome/Edge).</> },
|
||||||
<li>Open extension → Click <strong>Export</strong> → <strong>Export as JSON</strong>.</li>
|
{ step: 2, text: <>Go to <code className="bg-muted px-1.5 py-0.5 rounded text-primary">labs.google</code> (Whisk) or <code className="bg-muted px-1.5 py-0.5 rounded text-blue-500 dark:text-blue-400">facebook.com</code> (Meta) and login.</> },
|
||||||
<li>Paste into a Google Doc, Keep, Notes, or email it to yourself.</li>
|
{ step: 3, text: <>Open extension → Click <strong className="text-foreground">Export</strong> → <strong className="text-foreground">Export as JSON</strong>.</> },
|
||||||
<li>Open on phone and paste here.</li>
|
{ step: 4, text: <>Paste into a Google Doc, Keep, Notes, or email it to yourself.</> },
|
||||||
|
{ step: 5, text: <>Open on phone and paste here.</> }
|
||||||
|
].map((item) => (
|
||||||
|
<li key={item.step} className="flex gap-3 text-sm text-foreground/80">
|
||||||
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-[11px] font-black text-primary">{item.step}</span>
|
||||||
|
<span className="leading-tight">{item.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{platform === 'android' && (
|
{platform === 'android' && (
|
||||||
<div className="space-y-2 animate-in fade-in slide-in-from-left-2 duration-300">
|
<div className="space-y-4 animate-in fade-in slide-in-from-left-4 duration-500">
|
||||||
<p className="text-xs text-white/60">Android allows extensions via specific browsers.</p>
|
<p className="text-sm font-medium text-muted-foreground leading-relaxed">Android allows extensions via specific browsers.</p>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-xs">
|
<ol className="space-y-3">
|
||||||
<li>Install <strong>Kiwi Browser</strong> or <strong>Firefox Nightly</strong> from Play Store.</li>
|
{[
|
||||||
<li>Open Kiwi/Firefox and install <strong>Cookie-Editor</strong> from Chrome Web Store.</li>
|
{ step: 1, text: <>Install <strong className="text-foreground">Kiwi Browser</strong> or <strong className="text-foreground">Firefox Nightly</strong> from Play Store.</> },
|
||||||
<li>Login to the service (Whisk/Facebook).</li>
|
{ step: 2, text: <>Open Kiwi/Firefox and install <strong className="text-foreground">Cookie-Editor</strong> from Chrome Web Store.</> },
|
||||||
<li>Tap menu → Cookie-Editor → Export JSON.</li>
|
{ step: 3, text: <>Login to the service (Whisk/Facebook).</> },
|
||||||
|
{ step: 4, text: <>Tap menu → Cookie-Editor → Export JSON.</> }
|
||||||
|
].map((item) => (
|
||||||
|
<li key={item.step} className="flex gap-3 text-sm text-foreground/80">
|
||||||
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-[11px] font-black text-primary">{item.step}</span>
|
||||||
|
<span className="leading-tight">{item.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{platform === 'ios' && (
|
{platform === 'ios' && (
|
||||||
<div className="space-y-2 animate-in fade-in slide-in-from-left-2 duration-300">
|
<div className="space-y-4 animate-in fade-in slide-in-from-left-4 duration-500">
|
||||||
<p className="text-xs text-white/60">iPhone is restrictive. Syncing from Desktop is recommended.</p>
|
<p className="text-sm font-medium text-muted-foreground leading-relaxed">iPhone is restrictive. Syncing from Desktop is recommended.</p>
|
||||||
<div className="p-2 bg-amber-500/10 border border-amber-500/20 rounded-lg text-[10px] text-amber-200">
|
<div className="p-4 bg-amber-500/5 border border-amber-500/20 rounded-2xl text-xs text-amber-700 dark:text-amber-200/80 leading-relaxed shadow-sm">
|
||||||
<strong>Alternative:</strong> Use "Alook Browser" app (Paid) which acts like a desktop browser with developer tools.
|
<strong className="text-amber-800 dark:text-amber-100 block mb-1">Alternative:</strong> Use "Alook Browser" app (Paid) which acts like a desktop browser with developer tools.
|
||||||
</div>
|
</div>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-xs">
|
<ol className="space-y-3">
|
||||||
<li>Use <strong>Method 1 (Desktop Sync)</strong> - it's much faster.</li>
|
{[
|
||||||
<li>Or access this site on your Mac/PC and configure it there first.Settings are saved to the browser, so this only works if you sync local storage or use the same device.</li>
|
{ step: 1, text: <>Use <strong className="text-foreground">Method 1 (Desktop Sync)</strong> - it's much faster.</> },
|
||||||
|
{ step: 2, text: <>Or access this site on your Mac/PC and configure it there first. Settings are saved to the browser.</> }
|
||||||
|
].map((item) => (
|
||||||
|
<li key={item.step} className="flex gap-3 text-sm text-foreground/80">
|
||||||
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-[11px] font-black text-primary">{item.step}</span>
|
||||||
|
<span className="leading-tight">{item.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { Sparkles, LayoutGrid, Clock, Settings, User } from 'lucide-react';
|
import { Sparkles, LayoutGrid, Clock, Settings, User, Sun, Moon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useTheme } from '@/components/theme-provider';
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { currentView, setCurrentView, setSelectionMode } = useStore();
|
const { currentView, setCurrentView, setSelectionMode } = useStore();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'gallery', label: 'Create', icon: Sparkles },
|
{ id: 'gallery', label: 'Create', icon: Sparkles },
|
||||||
|
|
@ -17,11 +29,11 @@ export function Navbar() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border">
|
<div className="fixed top-0 left-0 right-0 z-50 glass-panel border-b border-border shadow-soft md:hidden">
|
||||||
{/* Yellow Accent Line */}
|
{/* Visual Highlight Line */}
|
||||||
<div className="h-1 w-full bg-primary" />
|
<div className="h-0.5 w-full bg-gradient-to-r from-transparent via-primary/50 to-transparent" />
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-4 h-16 max-w-7xl mx-auto">
|
<div className="flex items-center justify-between px-6 h-16 max-w-7xl mx-auto">
|
||||||
{/* Logo Area */}
|
{/* Logo Area */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
<div className="h-10 w-10 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||||
|
|
@ -31,7 +43,7 @@ export function Navbar() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center Navigation (Desktop) */}
|
{/* Center Navigation (Desktop) */}
|
||||||
<div className="hidden md:flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/50">
|
<div className="hidden md:flex items-center gap-1 bg-muted/20 p-1 rounded-full border border-border/50">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|
@ -43,7 +55,7 @@ export function Navbar() {
|
||||||
"flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all",
|
"flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all",
|
||||||
currentView === item.id
|
currentView === item.id
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
|
|
@ -54,6 +66,12 @@ export function Navbar() {
|
||||||
|
|
||||||
{/* Right Actions */}
|
{/* Right Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{mounted ? (theme === 'dark' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />) : <div className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentView('settings')}
|
onClick={() => setCurrentView('settings')}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -66,62 +84,16 @@ export function Navbar() {
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="h-8 w-px bg-border mx-1" />
|
<div className="h-8 w-px bg-border mx-1" />
|
||||||
<button className="flex items-center gap-2 pl-1 pr-3 py-1 bg-card hover:bg-secondary border border-border rounded-full transition-colors">
|
<button className="flex items-center gap-2 pl-1 pr-4 py-1.5 bg-card/50 hover:bg-secondary/20 border border-border/50 rounded-full transition-all group active:scale-95">
|
||||||
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-bold">
|
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-[10px] font-bold ring-2 ring-primary/10 group-hover:ring-primary/20 transition-all">
|
||||||
KV
|
KV
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium hidden sm:block">Khoa Vo</span>
|
<span className="text-xs font-semibold hidden sm:block text-foreground/80 group-hover:text-foreground">Khoa Vo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
|
||||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#18181B]/90 backdrop-blur-xl border-t border-white/10 safe-area-bottom">
|
|
||||||
<div className="flex items-center justify-around h-16 px-2">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => {
|
|
||||||
setCurrentView(item.id as any);
|
|
||||||
if (item.id === 'history') setSelectionMode(null);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
|
|
||||||
currentView === item.id
|
|
||||||
? "text-primary"
|
|
||||||
: "text-white/40 hover:text-white/80"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn(
|
|
||||||
"p-1.5 rounded-full transition-all",
|
|
||||||
currentView === item.id ? "bg-primary/10" : "bg-transparent"
|
|
||||||
)}>
|
|
||||||
<item.icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-medium">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{/* Settings Item for Mobile */}
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentView('settings')}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
|
|
||||||
currentView === 'settings'
|
|
||||||
? "text-primary"
|
|
||||||
: "text-white/40 hover:text-white/80"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn(
|
|
||||||
"p-1.5 rounded-full transition-all",
|
|
||||||
currentView === 'settings' ? "bg-primary/10" : "bg-transparent"
|
|
||||||
)}>
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-medium">Settings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -434,28 +434,25 @@ export function PromptHero() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-3xl mx-auto my-4 md:my-6 px-3 md:px-4">
|
<div className="w-full max-w-lg md:max-w-3xl mx-auto mb-8 transition-all">
|
||||||
{/* Error/Warning Notification Toast */}
|
{/* Error/Warning Notification Toast */}
|
||||||
{errorNotification && (
|
{errorNotification && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mb-4 p-3 rounded-lg border flex items-start gap-3 animate-in slide-in-from-top-4 duration-300",
|
"mb-4 p-3 rounded-xl border flex items-start gap-3 animate-in fade-in slide-in-from-top-4 duration-300",
|
||||||
errorNotification.type === 'warning'
|
errorNotification.type === 'warning'
|
||||||
? "bg-amber-500/10 border-amber-500/30 text-amber-200"
|
? "bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400"
|
||||||
: "bg-red-500/10 border-red-500/30 text-red-200"
|
: "bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"
|
||||||
)}>
|
)}>
|
||||||
<AlertTriangle className={cn(
|
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
"h-5 w-5 shrink-0 mt-0.5",
|
|
||||||
errorNotification.type === 'warning' ? "text-amber-400" : "text-red-400"
|
|
||||||
)} />
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-semibold">
|
||||||
{errorNotification.type === 'warning' ? '⚠️ Content Moderation' : '❌ Generation Error'}
|
{errorNotification.type === 'warning' ? 'Content Moderation' : 'Generation Error'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs mt-1 opacity-80">{errorNotification.message}</p>
|
<p className="text-xs mt-1 opacity-90 leading-relaxed">{errorNotification.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setErrorNotification(null)}
|
onClick={() => setErrorNotification(null)}
|
||||||
className="p-1 hover:bg-white/10 rounded-full transition-colors"
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -463,238 +460,133 @@ export function PromptHero() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"relative flex flex-col gap-3 rounded-2xl bg-[#1A1A1E] border border-white/5 transition-all z-10",
|
"bg-card rounded-3xl p-6 md:p-8 shadow-premium border border-border/50 relative overflow-hidden transition-all duration-500",
|
||||||
isGenerating && "ring-1 ring-purple-500/30"
|
isGenerating && "ring-2 ring-primary/20 border-primary/50 shadow-lg shadow-primary/10"
|
||||||
)}>
|
)}>
|
||||||
|
{/* Visual Background Accent */}
|
||||||
|
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-0 -ml-16 -mb-16 w-64 h-64 bg-secondary/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
{/* Header / Title + Provider Toggle */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-8 relative z-10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-amber-500/20 to-purple-600/20 border border-white/5 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-primary to-violet-700 flex items-center justify-center text-white shadow-lg shadow-primary/20">
|
||||||
{settings.provider === 'meta' ? (
|
{settings.provider === 'meta' ? <Brain className="h-5 w-5" /> : <Sparkles className="h-5 w-5" />}
|
||||||
<Brain className="h-4 w-4 text-blue-400" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="h-4 w-4 text-amber-300" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white tracking-tight flex items-center gap-2">
|
<h2 className="font-extrabold text-xl text-foreground tracking-tight">Create</h2>
|
||||||
Create
|
<span className="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">
|
||||||
<span className="text-[10px] font-medium text-white/40 border-l border-white/10 pl-2">
|
Powered by <span className="text-secondary">{settings.provider === 'meta' ? 'Meta AI' : 'Google Whisk'}</span>
|
||||||
by <span className={cn(
|
|
||||||
settings.provider === 'meta' ? "text-blue-400" :
|
|
||||||
"text-amber-300"
|
|
||||||
)}>
|
|
||||||
{settings.provider === 'meta' ? 'Meta AI' :
|
|
||||||
'Whisk'}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex bg-muted/50 p-1 rounded-xl border border-border/50">
|
||||||
{/* Provider Toggle */}
|
|
||||||
<div className="flex bg-black/40 p-0.5 rounded-lg border border-white/10 backdrop-blur-md scale-90 origin-right">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSettings({ provider: 'whisk' })}
|
onClick={() => setSettings({ provider: settings.provider === 'meta' ? 'whisk' : 'meta' })}
|
||||||
className={cn(
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-card shadow-sm border border-border/50 text-xs font-bold text-foreground hover:bg-muted transition-all active:scale-95"
|
||||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-medium transition-all",
|
title="Switch Provider"
|
||||||
settings.provider === 'whisk' || !settings.provider
|
|
||||||
? "bg-white/10 text-white shadow-sm"
|
|
||||||
: "text-white/40 hover:text-white/70 hover:bg-white/5"
|
|
||||||
)}
|
|
||||||
title="Google Whisk"
|
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3 w-3" />
|
<Settings2 className="h-3.5 w-3.5 text-primary" />
|
||||||
<span className="hidden sm:inline">Whisk</span>
|
<span>Switch</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSettings({ provider: 'meta' })}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-medium transition-all",
|
|
||||||
settings.provider === 'meta'
|
|
||||||
? "bg-white/10 text-white shadow-sm"
|
|
||||||
: "text-white/40 hover:text-white/70 hover:bg-white/5"
|
|
||||||
)}
|
|
||||||
title="Meta AI"
|
|
||||||
>
|
|
||||||
<Brain className="h-3 w-3" />
|
|
||||||
<span className="hidden sm:inline">Meta</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<div className="relative group z-20">
|
<div className="relative mb-6 group z-10">
|
||||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-xl opacity-0 group-hover:opacity-100 transition duration-500 -z-10" />
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder="Describe your imagination..."
|
className="w-full bg-muted/30 border border-border/50 rounded-2xl p-5 text-sm md:text-base focus:ring-2 focus:ring-primary/20 focus:border-primary/50 outline-none resize-none min-h-[140px] placeholder-muted-foreground/50 text-foreground transition-all shadow-inner"
|
||||||
className="relative w-full resize-none bg-[#0E0E10] rounded-lg p-3 text-sm md:text-base text-white placeholder:text-white/20 outline-none min-h-[60px] border border-white/10 focus:border-purple-500/50 transition-all shadow-inner"
|
placeholder="What's on your mind? Describe your vision..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls Area */}
|
{/* Reference Upload Grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6 relative z-10">
|
||||||
{/* Mobile View: Unified Stack */}
|
|
||||||
<div className="md:hidden flex flex-col gap-3 pt-2">
|
|
||||||
{/* Unified Horizontal Scroll Toolbar */}
|
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-none -mx-1 px-1 snap-x">
|
|
||||||
|
|
||||||
{/* Reference Pills */}
|
|
||||||
{((settings.provider === 'meta' ? ['subject'] : ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
|
{((settings.provider === 'meta' ? ['subject'] : ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
|
||||||
const refs = references[cat] || [];
|
const refs = references[cat] || [];
|
||||||
const hasRefs = refs.length > 0;
|
const hasRefs = refs.length > 0;
|
||||||
const isUploading = uploadingRefs[cat];
|
const isUploading = uploadingRefs[cat];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => toggleReference(cat)}
|
onClick={() => toggleReference(cat)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, cat)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 flex-shrink-0 rounded-full px-3 py-1.5 text-xs font-medium transition-all border relative overflow-hidden min-h-[36px] snap-start",
|
"flex flex-col items-center justify-center gap-2 py-4 rounded-2xl border transition-all relative overflow-hidden group/btn shadow-soft",
|
||||||
hasRefs
|
hasRefs
|
||||||
? "bg-purple-500/10 text-purple-200 border-purple-500/30"
|
? "bg-primary/5 border-primary/30"
|
||||||
: "bg-white/5 text-white/40 border-white/5"
|
: "bg-muted/50 hover:bg-muted border-border/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
) : hasRefs ? (
|
) : hasRefs ? (
|
||||||
<span className="bg-purple-500/30 text-purple-100 rounded-full w-4 h-4 flex items-center justify-center text-[9px]">{refs.length}</span>
|
<div className="relative pt-1">
|
||||||
|
<div className="flex -space-x-2.5 justify-center">
|
||||||
|
{refs.slice(0, 3).map((ref, idx) => (
|
||||||
|
<img key={ref.id} src={ref.thumbnail} className="w-8 h-8 rounded-full object-cover ring-2 ring-background shadow-md" style={{ zIndex: 10 - idx }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -top-1 -right-3 bg-secondary text-secondary-foreground text-[10px] font-black px-1.5 py-0.5 rounded-full shadow-sm">{refs.length}</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Upload className="h-3 w-3" />
|
<div className="p-2 rounded-xl bg-background/50 group-hover/btn:bg-primary/10 transition-colors">
|
||||||
|
<Upload className="h-4 w-4 text-muted-foreground group-hover/btn:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="capitalize">{cat}</span>
|
<span className={cn(
|
||||||
{/* Clear Button (Hidden logic for simplicity on mobile pill, user can tap to open/toggle or long press? For now simplify to toggle) */}
|
"text-[10px] uppercase font-black tracking-widest transition-colors",
|
||||||
|
hasRefs ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Settings & Generate Row */}
|
||||||
<div className="w-px h-6 bg-white/10 flex-shrink-0 mx-2" />
|
<div className="flex items-center gap-2 relative z-10">
|
||||||
|
<div className="flex items-center gap-0.5 bg-muted/50 p-1 rounded-2xl border border-border/50 shrink-0">
|
||||||
{/* Settings Pills */}
|
|
||||||
<button
|
<button
|
||||||
onClick={settings.provider === 'meta' ? undefined : cycleImageCount}
|
onClick={settings.provider === 'meta' ? undefined : cycleImageCount}
|
||||||
className={cn("flex items-center gap-1.5 flex-shrink-0 rounded-full px-3 py-1.5 text-xs font-medium transition-all border border-white/5 bg-white/5 text-white/60 min-h-[36px] snap-start",
|
className={cn(
|
||||||
settings.provider === 'meta' && "opacity-50 cursor-not-allowed"
|
"flex items-center gap-1.5 px-2.5 py-2 rounded-xl transition-all whitespace-nowrap",
|
||||||
|
settings.provider === 'meta' ? "opacity-30 cursor-not-allowed" : "hover:bg-card"
|
||||||
)}
|
)}
|
||||||
|
title="Image Count"
|
||||||
>
|
>
|
||||||
<Hash className="h-3 w-3" />
|
<Hash className="h-3.5 w-3.5 text-primary" />
|
||||||
<span>{settings.provider === 'meta' ? 4 : settings.imageCount}</span>
|
<span className="text-xs font-bold text-foreground">{settings.provider === 'meta' ? 4 : settings.imageCount}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={nextAspectRatio}
|
onClick={nextAspectRatio}
|
||||||
className="flex items-center gap-1.5 flex-shrink-0 rounded-full px-3 py-1.5 text-xs font-medium transition-all border border-white/5 bg-white/5 text-white/60 min-h-[36px] snap-start"
|
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl hover:bg-card transition-all whitespace-nowrap"
|
||||||
|
title="Aspect Ratio"
|
||||||
>
|
>
|
||||||
<span>{settings.aspectRatio}</span>
|
<Maximize2 className="h-3.5 w-3.5 text-secondary" />
|
||||||
|
<span className="text-xs font-bold text-foreground">{settings.aspectRatio}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSettings({ preciseMode: !settings.preciseMode })}
|
onClick={() => setSettings({ preciseMode: !settings.preciseMode })}
|
||||||
className={cn("flex items-center gap-1.5 flex-shrink-0 rounded-full px-3 py-1.5 text-xs font-medium transition-all border border-white/5 min-h-[36px] snap-start",
|
|
||||||
settings.preciseMode ? "bg-amber-500/10 text-amber-200 border-amber-500/30" : "bg-white/5 text-white/60"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>Precise</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Full Width Generate Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={isGenerating || !prompt.trim()}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden w-full rounded-xl font-bold text-base text-white shadow-lg transition-all active:scale-95 group border border-white/10 min-h-[48px]",
|
"px-2.5 py-2 rounded-xl transition-all font-black text-[10px] tracking-tight uppercase whitespace-nowrap",
|
||||||
"bg-gradient-to-r from-purple-600 to-indigo-600 hover:shadow-indigo-500/25"
|
settings.preciseMode
|
||||||
|
? "bg-secondary text-secondary-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-card"
|
||||||
)}
|
)}
|
||||||
|
title="Precise Mode"
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex items-center justify-center gap-1.5">
|
Precise
|
||||||
{isGenerating ? (
|
|
||||||
<>
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
||||||
<span className="animate-pulse">Dreaming...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
<span>Generate</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop Layout: Split Controls (Hidden on Mobile) */}
|
|
||||||
<div className="hidden md:flex flex-row items-center justify-between gap-3 pt-1">
|
|
||||||
|
|
||||||
{/* Left: References */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{((settings.provider === 'meta' ? ['subject'] : ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
|
|
||||||
const refs = references[cat] || [];
|
|
||||||
const hasRefs = refs.length > 0;
|
|
||||||
const isUploading = uploadingRefs[cat];
|
|
||||||
return (
|
|
||||||
<div key={cat} className="relative group">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleReference(cat)}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={(e) => handleDrop(e, cat)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[10px] font-medium transition-all border relative overflow-hidden",
|
|
||||||
hasRefs
|
|
||||||
? "bg-purple-500/10 text-purple-200 border-purple-500/30 hover:bg-purple-500/20"
|
|
||||||
: "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10",
|
|
||||||
isUploading && "animate-pulse cursor-wait"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
||||||
) : hasRefs ? (
|
|
||||||
<div className="flex -space-x-1.5">
|
|
||||||
{refs.slice(0, 4).map((ref, idx) => (
|
|
||||||
<img key={ref.id} src={ref.thumbnail} alt="" className="h-4 w-4 rounded-sm object-cover ring-1 ring-white/20" style={{ zIndex: 10 - idx }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Upload className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
<span className="capitalize tracking-wide">{cat}</span>
|
|
||||||
{refs.length > 0 && <span className="text-[9px] bg-purple-500/30 text-purple-100 rounded-full px-1.5 h-3 flex items-center">{refs.length}</span>}
|
|
||||||
</button>
|
|
||||||
{hasRefs && !isUploading && (
|
|
||||||
<button
|
|
||||||
className="absolute -top-1 -right-1 p-0.5 rounded-full bg-red-500/80 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
|
|
||||||
onClick={(e) => { e.stopPropagation(); clearReferences(cat); }}
|
|
||||||
>
|
|
||||||
<X className="h-2 w-2" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Settings & Generate */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-0.5 bg-[#0E0E10] p-1 rounded-lg border border-white/10">
|
|
||||||
<button onClick={settings.provider === 'meta' ? undefined : cycleImageCount} className={cn("flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium transition-colors", settings.provider === 'meta' ? "text-blue-200/50 cursor-not-allowed" : "text-white/60 hover:text-white hover:bg-white/5")}>
|
|
||||||
<Hash className="h-3 w-3 opacity-70" />
|
|
||||||
<span>{settings.provider === 'meta' ? 4 : settings.imageCount}</span>
|
|
||||||
</button>
|
|
||||||
<div className="w-px h-3 bg-white/10 mx-1" />
|
|
||||||
<button onClick={nextAspectRatio} className="px-2 py-1 rounded-md text-[10px] font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors">
|
|
||||||
<span className="opacity-70">Ratio:</span>
|
|
||||||
<span className="ml-1 text-white/80">{settings.aspectRatio}</span>
|
|
||||||
</button>
|
|
||||||
<div className="w-px h-3 bg-white/10 mx-1" />
|
|
||||||
<button onClick={() => setSettings({ preciseMode: !settings.preciseMode })} className={cn("px-2 py-1 rounded-md text-[10px] font-medium transition-all flex items-center gap-1", settings.preciseMode ? "text-amber-300 bg-amber-500/10 ring-1 ring-amber-500/30" : "text-white/40 hover:text-white hover:bg-white/5")}>
|
|
||||||
<span>Precise</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -702,80 +594,29 @@ export function PromptHero() {
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={isGenerating || !prompt.trim()}
|
disabled={isGenerating || !prompt.trim()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden px-4 md:py-1.5 rounded-lg font-bold text-sm text-white shadow-lg transition-all active:scale-95 group border border-white/10 active:scale-95",
|
"group/gen flex-1 min-w-[120px] bg-primary hover:bg-violet-700 text-white font-black uppercase tracking-widest text-[11px] md:text-sm h-[52px] rounded-2xl shadow-premium-lg flex items-center justify-center gap-2 transition-all active:scale-[0.97] border-b-4 border-violet-800 disabled:opacity-50 disabled:cursor-not-allowed disabled:border-transparent",
|
||||||
"bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 hover:shadow-indigo-500/25"
|
isGenerating && "animate-pulse"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex items-center gap-1.5">
|
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
<span className="animate-pulse">Dreaming...</span>
|
<span>Generating...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Sparkles className="h-3 w-3 group-hover:rotate-12 transition-transform" />
|
<Sparkles className="h-4 w-4 group-hover/gen:rotate-12 transition-transform" />
|
||||||
<span>Generate</span>
|
<span>Dream Big</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden File Inputs (Shared) */}
|
{/* Hidden File Inputs */}
|
||||||
<input type="file" ref={fileInputRefs.subject} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'subject')} />
|
<input type="file" ref={fileInputRefs.subject} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'subject')} />
|
||||||
<input type="file" ref={fileInputRefs.scene} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'scene')} />
|
<input type="file" ref={fileInputRefs.scene} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'scene')} />
|
||||||
<input type="file" ref={fileInputRefs.style} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'style')} />
|
<input type="file" ref={fileInputRefs.style} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'style')} />
|
||||||
|
|
||||||
{/* Reference Preview Panel - shows when any references exist */}
|
|
||||||
{
|
|
||||||
(references.subject?.length || references.scene?.length || references.style?.length) ? (
|
|
||||||
<div className="mt-4 p-3 rounded-xl bg-white/5 border border-white/10">
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{(['subject', 'scene', 'style'] as ReferenceCategory[]).map((cat) => {
|
|
||||||
const refs = references[cat] || [];
|
|
||||||
if (refs.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={cat} className="flex-1 min-w-[120px]">
|
|
||||||
<div className="text-[10px] uppercase tracking-wider text-white/40 mb-2 flex items-center justify-between">
|
|
||||||
<span>{cat}</span>
|
|
||||||
<span className="text-purple-300">{refs.length}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{refs.map((ref) => (
|
|
||||||
<div key={ref.id} className="relative group/thumb">
|
|
||||||
<img
|
|
||||||
src={ref.thumbnail}
|
|
||||||
alt=""
|
|
||||||
className="h-12 w-12 md:h-10 md:w-10 rounded object-cover ring-1 ring-white/10 group-hover/thumb:ring-purple-500/50 transition-all"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => removeReference(cat, ref.id)}
|
|
||||||
className="absolute -top-1 -right-1 p-0.5 rounded-full bg-red-500 text-white opacity-0 group-hover/thumb:opacity-100 transition-opacity hover:bg-red-600"
|
|
||||||
title="Remove this reference"
|
|
||||||
>
|
|
||||||
<X className="h-2.5 w-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* Add more button */}
|
|
||||||
<button
|
|
||||||
onClick={() => openFilePicker(cat)}
|
|
||||||
className="h-10 w-10 rounded border border-dashed border-white/20 flex items-center justify-center text-white/30 hover:text-white/60 hover:border-white/40 transition-colors"
|
|
||||||
title={`Add more ${cat} references`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">+</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
</div >
|
|
||||||
</div >
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,23 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortMode, setSortMode] = useState<'all' | 'latest' | 'history' | 'foryou'>('all');
|
const [sortMode, setSortMode] = useState<'all' | 'latest' | 'history' | 'foryou'>('all');
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
const fetchPrompts = async () => {
|
const fetchPrompts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/prompts');
|
const res = await fetch('/api/prompts');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: PromptCache = await res.json();
|
const data: PromptCache = await res.json();
|
||||||
setPrompts(data.prompts);
|
setPrompts(data.prompts);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Server returned ${res.status}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch prompts", error);
|
console.error("Failed to fetch prompts", error);
|
||||||
|
setError("Unable to load the prompt library. Please check your connection.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -34,12 +41,14 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
|
||||||
|
|
||||||
const syncPrompts = async () => {
|
const syncPrompts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const syncRes = await fetch('/api/prompts/sync', { method: 'POST' });
|
const syncRes = await fetch('/api/prompts/sync', { method: 'POST' });
|
||||||
if (!syncRes.ok) throw new Error('Sync failed');
|
if (!syncRes.ok) throw new Error('Sync failed');
|
||||||
await fetchPrompts();
|
await fetchPrompts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to sync prompts", error);
|
console.error("Failed to sync prompts", error);
|
||||||
|
setError("Failed to sync new prompts from the community.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -170,85 +179,111 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
|
||||||
const uniqueSources = ['All', ...Array.from(new Set(prompts.map(p => p.source)))].filter(Boolean);
|
const uniqueSources = ['All', ...Array.from(new Set(prompts.map(p => p.source)))].filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-8 pb-32">
|
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-10 pb-32">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col items-center text-center gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="p-3 bg-primary/10 rounded-xl text-primary">
|
<div className="p-4 bg-primary/10 rounded-2xl text-primary shadow-sm">
|
||||||
<Sparkles className="h-6 w-6" />
|
<Sparkles className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">Prompt Library</h2>
|
<h2 className="text-3xl font-black tracking-tight">Prompt Library</h2>
|
||||||
<p className="text-muted-foreground">Curated inspiration from the community.</p>
|
<p className="text-muted-foreground text-sm font-medium">Curated inspiration from the community.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
<div className="flex flex-col items-center gap-4 w-full max-w-2xl">
|
||||||
|
<div className="relative flex-1 w-full group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search prompts..."
|
||||||
|
className="px-5 py-3 pl-12 pr-28 rounded-2xl bg-card border border-border/50 focus:border-primary focus:ring-4 focus:ring-primary/10 focus:outline-none w-full transition-all shadow-soft"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Action Buttons inside search bar */}
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 p-1 bg-muted/50 rounded-xl border border-border/30">
|
||||||
<button
|
<button
|
||||||
onClick={generateMissingPreviews}
|
onClick={generateMissingPreviews}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 hover:bg-secondary rounded-full transition-colors",
|
"p-1.5 hover:bg-card rounded-lg transition-all active:scale-90",
|
||||||
generating && "animate-pulse text-yellow-500"
|
generating && "animate-pulse text-primary bg-card shadow-sm"
|
||||||
)}
|
)}
|
||||||
title="Auto-Generate Missing Previews"
|
title="Renew/Generate Previews"
|
||||||
>
|
>
|
||||||
<ImageIcon className="h-5 w-5" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-border/50 mx-0.5" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={syncPrompts}
|
onClick={syncPrompts}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-2 hover:bg-secondary rounded-full transition-colors"
|
className="p-1.5 hover:bg-card rounded-lg transition-all active:scale-90"
|
||||||
title="Sync from GitHub"
|
title="Sync Library"
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-5 w-5", loading && "animate-spin")} />
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
</button>
|
</button>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
</div>
|
||||||
placeholder="Search prompts..."
|
|
||||||
className="px-4 py-2 rounded-lg bg-card border focus:border-primary focus:outline-none w-full md:w-64"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-6 rounded-3xl flex flex-col items-center gap-4 text-center max-w-md mx-auto">
|
||||||
|
<p className="font-bold">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchPrompts()}
|
||||||
|
className="px-6 py-2 bg-destructive text-white rounded-full text-xs font-black uppercase tracking-widest hover:bg-red-600 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Retry Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{generating && (
|
{generating && (
|
||||||
<div className="bg-primary/10 border border-primary/20 text-primary p-4 rounded-xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
<div className="bg-primary/5 border border-primary/20 text-primary p-4 rounded-2xl flex items-center justify-center gap-3 animate-in fade-in slide-in-from-top-2 max-w-2xl mx-auto shadow-sm">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
<span className="font-medium">Generating preview images for library prompts... This may take a while.</span>
|
<span className="font-bold text-xs uppercase tracking-wider">Generating library previews...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Smart Tabs */}
|
{/* Smart Tabs */}
|
||||||
<div className="flex items-center gap-1 bg-secondary/30 p-1 rounded-xl w-fit">
|
<div className="flex justify-center">
|
||||||
|
<div className="flex items-center gap-1 bg-muted/50 p-1.5 rounded-2xl border border-border/50 shadow-soft">
|
||||||
{(['all', 'latest', 'history', 'foryou'] as const).map(mode => (
|
{(['all', 'latest', 'history', 'foryou'] as const).map(mode => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => setSortMode(mode)}
|
onClick={() => setSortMode(mode)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
|
"px-6 py-2.5 rounded-xl text-xs font-black transition-all capitalize uppercase tracking-tighter active:scale-95",
|
||||||
sortMode === mode
|
sortMode === mode
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mode === 'foryou' ? 'For You' : mode}
|
{mode === 'foryou' ? 'For You' : mode}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sub-Categories (only show if NOT history/foryou to keep clean? Or keep it?) */}
|
{/* Sub-Categories */}
|
||||||
{sortMode === 'all' && (
|
{sortMode === 'all' && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2 justify-center max-w-4xl mx-auto">
|
||||||
{uniqueCategories.map(cat => (
|
{uniqueCategories.map(cat => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setSelectedCategory(cat)}
|
onClick={() => setSelectedCategory(cat)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
|
"px-5 py-2 rounded-2xl text-xs font-bold transition-all border active:scale-95",
|
||||||
selectedCategory === cat
|
selectedCategory === cat
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-secondary text-secondary-foreground border-transparent shadow-md"
|
||||||
: "bg-card hover:bg-secondary text-muted-foreground"
|
: "bg-card hover:bg-muted text-muted-foreground border-border/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
|
|
@ -258,17 +293,17 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Source Filter */}
|
{/* Source Filter */}
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap gap-3 items-center justify-center pt-2">
|
||||||
<span className="text-sm font-medium text-muted-foreground mr-2">Sources:</span>
|
<span className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60 mr-1">Sources:</span>
|
||||||
{uniqueSources.map(source => (
|
{uniqueSources.map(source => (
|
||||||
<button
|
<button
|
||||||
key={source}
|
key={source}
|
||||||
onClick={() => setSelectedSource(source)}
|
onClick={() => setSelectedSource(source)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1 rounded-full text-xs font-medium transition-colors border",
|
"px-4 py-1.5 rounded-xl text-[10px] font-black tracking-widest uppercase transition-all border active:scale-95",
|
||||||
selectedSource === source
|
selectedSource === source
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
? "bg-primary/10 text-primary border-primary/20 shadow-sm"
|
||||||
: "bg-card hover:bg-secondary text-muted-foreground border-secondary"
|
: "bg-muted/30 hover:bg-muted text-muted-foreground/70 border-border/30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{source}
|
{source}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { Save, Sparkles, Brain, Settings2 } from 'lucide-react';
|
import { Save, Sparkles, Brain, Settings2, Moon, Sun, Monitor, Check } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTheme } from '@/components/theme-provider';
|
||||||
|
|
||||||
import { MobileCookieInstructions } from './MobileCookieInstructions';
|
import { MobileCookieInstructions } from './MobileCookieInstructions';
|
||||||
|
|
||||||
|
|
@ -16,6 +17,27 @@ const providers: { id: Provider; name: string; icon: any; description: string }[
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { settings, setSettings } = useStore();
|
const { settings, setSettings } = useStore();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ThemeButton = ({ theme: t, icon: Icon, label }: { theme: 'light' | 'dark' | 'system', icon: any, label: string }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-2 p-3 rounded-xl border transition-all active:scale-95",
|
||||||
|
mounted && theme === t
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-card hover:bg-muted border-border text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
// Local state for form fields
|
// Local state for form fields
|
||||||
const [provider, setProvider] = React.useState<Provider>(settings.provider || 'whisk');
|
const [provider, setProvider] = React.useState<Provider>(settings.provider || 'whisk');
|
||||||
|
|
@ -25,6 +47,8 @@ export function Settings() {
|
||||||
const [metaCookies, setMetaCookies] = React.useState(settings.metaCookies || '');
|
const [metaCookies, setMetaCookies] = React.useState(settings.metaCookies || '');
|
||||||
const [facebookCookies, setFacebookCookies] = React.useState(settings.facebookCookies || '');
|
const [facebookCookies, setFacebookCookies] = React.useState(settings.facebookCookies || '');
|
||||||
const [saved, setSaved] = React.useState(false);
|
const [saved, setSaved] = React.useState(false);
|
||||||
|
const [whiskVerified, setWhiskVerified] = React.useState(false);
|
||||||
|
const [metaVerified, setMetaVerified] = React.useState(false);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setSettings({
|
setSettings({
|
||||||
|
|
@ -40,148 +64,283 @@ export function Settings() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-8 p-4 md:p-8">
|
<div className="space-y-8 pb-32">
|
||||||
<div>
|
{/* Header Section */}
|
||||||
<h2 className="text-2xl font-bold mb-2">Settings</h2>
|
<div className="px-2">
|
||||||
<p className="text-muted-foreground">Configure your AI image generation provider.</p>
|
<h2 className="text-2xl font-black text-foreground tracking-tight">Settings</h2>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mt-1">Configure your AI preferences and API credentials.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider Selection */}
|
{/* General Preferences Card */}
|
||||||
<div className="space-y-3">
|
<div className="bg-card rounded-3xl p-6 md:p-8 shadow-premium border border-border/50 space-y-8 relative overflow-hidden">
|
||||||
<label className="text-sm font-medium">Image Generation Provider</label>
|
<div className="absolute top-0 right-0 -mr-12 -mt-12 w-48 h-48 bg-primary/5 rounded-full blur-2xl" />
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
|
<section className="space-y-6 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Settings2 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-extrabold text-lg tracking-tight">Appearance</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<ThemeButton theme="light" icon={Sun} label="Light" />
|
||||||
|
<ThemeButton theme="dark" icon={Moon} label="Dark" />
|
||||||
|
<ThemeButton theme="system" icon={Monitor} label="System" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-border/50" />
|
||||||
|
|
||||||
|
<section className="space-y-6 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-extrabold text-lg tracking-tight">Default Engine</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{providers.map((p) => (
|
{providers.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => setProvider(p.id)}
|
onClick={() => setProvider(p.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-2 p-4 md:p-4 rounded-xl border-2 transition-all min-h-[100px] active:scale-95",
|
"flex items-center gap-4 p-4 rounded-2xl border-2 transition-all active:scale-[0.98] text-left",
|
||||||
provider === p.id
|
provider === p.id
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/5 ring-4 ring-primary/10"
|
||||||
: "border-border hover:border-primary/50 bg-card"
|
: "border-border/50 hover:border-primary/30 bg-background/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p.icon className={cn(
|
<div className={cn(
|
||||||
"h-7 w-7 md:h-6 md:w-6",
|
"p-3 rounded-xl transition-colors",
|
||||||
provider === p.id ? "text-primary" : "text-muted-foreground"
|
provider === p.id ? "bg-primary text-white" : "bg-muted text-muted-foreground"
|
||||||
)} />
|
)}>
|
||||||
<span className={cn(
|
<p.icon className="h-6 w-6" />
|
||||||
"font-medium text-sm",
|
</div>
|
||||||
provider === p.id ? "text-primary" : ""
|
<div>
|
||||||
)}>{p.name}</span>
|
<p className={cn("font-bold text-sm", provider === p.id ? "text-primary" : "text-foreground")}>{p.name}</p>
|
||||||
<span className="text-xs text-muted-foreground text-center">{p.description}</span>
|
<p className="text-[10px] uppercase font-black tracking-widest text-muted-foreground mt-0.5">{p.description}</p>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider-specific settings */}
|
{/* Provider Credentials Card */}
|
||||||
<div className="space-y-4 p-4 rounded-xl bg-card border">
|
<div className="bg-card rounded-3xl p-6 md:p-8 shadow-premium border border-border/50 relative overflow-hidden">
|
||||||
|
<div className="absolute bottom-0 left-0 -ml-12 -mb-12 w-48 h-48 bg-secondary/5 rounded-full blur-2xl" />
|
||||||
|
|
||||||
|
<section className="space-y-8 relative z-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-extrabold text-lg tracking-tight">API Credentials</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-2 px-6 py-2 bg-primary text-white rounded-full text-xs font-black uppercase tracking-widest hover:bg-violet-700 shadow-lg shadow-primary/20 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saved ? "Saved Configuration" : "Save Configuration"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-12">
|
||||||
|
{/* Whisk Settings */}
|
||||||
{provider === 'whisk' && (
|
{provider === 'whisk' && (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-left-4 duration-300">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||||
|
<h4 className="text-xs font-black uppercase tracking-widest text-foreground">Google Whisk Configuration</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
setWhiskCookies(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read clipboard', err);
|
||||||
|
alert('Please grant clipboard permissions to use this feature.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-muted/50 hover:bg-muted text-[10px] font-bold rounded-lg border border-border/50 transition-all flex items-center gap-1.5 active:scale-95"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-primary/40" />
|
||||||
|
Paste Cookies
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const isValid = whiskCookies.includes('PHPSESSID') || whiskCookies.length > 100;
|
||||||
|
if (isValid) {
|
||||||
|
setWhiskVerified(true);
|
||||||
|
setTimeout(() => setWhiskVerified(false), 3000);
|
||||||
|
} else {
|
||||||
|
alert("Whisk cookies might be incomplete.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-[10px] font-bold rounded-lg border transition-all active:scale-95 flex items-center gap-1.5",
|
||||||
|
whiskVerified
|
||||||
|
? "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400"
|
||||||
|
: "bg-primary/10 hover:bg-primary/20 text-primary border-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{whiskVerified ? <Check className="h-3 w-3" /> : null}
|
||||||
|
{whiskVerified ? "Verified" : "Verify"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<MobileCookieInstructions />
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Authentication Cookies</label>
|
||||||
<label className="text-sm font-medium pt-2">Google Whisk Cookies</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={whiskCookies}
|
value={whiskCookies}
|
||||||
onChange={(e) => setWhiskCookies(e.target.value)}
|
onChange={(e) => setWhiskCookies(e.target.value)}
|
||||||
placeholder="Paste your cookies here..."
|
placeholder="Paste your Whisk cookies here..."
|
||||||
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
|
className="w-full h-32 bg-background/50 border border-border/50 rounded-2xl p-4 text-xs font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary/50 outline-none resize-none transition-all"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<MobileCookieInstructions provider="whisk" />
|
||||||
Get from <a href="https://labs.google/fx/tools/whisk/project" target="_blank" className="underline hover:text-primary">Whisk</a> using <a href="https://cookie-editor.com/" target="_blank" className="underline hover:text-primary">Cookie-Editor</a>.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Meta AI Settings */}
|
||||||
{provider === 'meta' && (
|
{provider === 'meta' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
{/* Advanced Settings (Hidden by default) */}
|
<div className="flex items-center gap-2">
|
||||||
<details className="group mb-4">
|
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
<summary className="flex items-center gap-2 cursor-pointer text-xs text-white/40 hover:text-white/60 mb-2 select-none">
|
<h4 className="text-xs font-black uppercase tracking-widest text-foreground">Meta AI Configuration</h4>
|
||||||
<Settings2 className="h-3 w-3" />
|
|
||||||
<span>Advanced Configuration</span>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div className="pl-4 border-l border-white/5 space-y-4 mb-4">
|
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-secondary/30 border border-border/50">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<label className="text-sm font-medium text-white/70">Use Free API Wrapper</label>
|
|
||||||
<p className="text-[10px] text-muted-foreground">Running locally via Docker</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-xs ${useMetaFreeWrapper ? "text-primary font-medium" : "text-muted-foreground"}`}>{useMetaFreeWrapper ? "ON" : "OFF"}</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setUseMetaFreeWrapper(!useMetaFreeWrapper)}
|
onClick={() => {
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${useMetaFreeWrapper ? "bg-primary" : "bg-input"}`}
|
const isValid = metaCookies.length > 50 && facebookCookies.length > 50;
|
||||||
|
if (isValid) {
|
||||||
|
setMetaVerified(true);
|
||||||
|
setTimeout(() => setMetaVerified(false), 3000);
|
||||||
|
} else {
|
||||||
|
alert("Please ensure both Meta and Facebook cookies are pasted.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-[10px] font-bold rounded-lg border transition-all active:scale-95 flex items-center gap-1.5",
|
||||||
|
metaVerified
|
||||||
|
? "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400"
|
||||||
|
: "bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/20"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span className={`pointer-events-none block h-3.5 w-3.5 rounded-full bg-background shadow-lg ring-0 transition-transform ${useMetaFreeWrapper ? "translate-x-4" : "translate-x-0.5"}`} />
|
{metaVerified ? <Check className="h-3 w-3" /> : null}
|
||||||
|
{metaVerified ? "Verified" : "Verify Duo"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Configuration (Meta Specific) */}
|
||||||
|
<details className="group mb-4">
|
||||||
|
<summary className="flex items-center gap-2 cursor-pointer text-xs text-muted-foreground/50 hover:text-muted-foreground mb-4 select-none">
|
||||||
|
<Settings2 className="h-3 w-3" />
|
||||||
|
<span>Advanced Host Configuration</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="space-y-6 pl-4 border-l border-border/50 mb-6">
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-muted/30 border border-border/50">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-bold">Local Docker Wrapper</p>
|
||||||
|
<p className="text-[10px] uppercase font-black tracking-widest text-muted-foreground">Internal API Bridge</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setUseMetaFreeWrapper(!useMetaFreeWrapper)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors outline-none",
|
||||||
|
useMetaFreeWrapper ? "bg-primary" : "bg-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||||
|
useMetaFreeWrapper ? "translate-x-6" : "translate-x-1"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{useMetaFreeWrapper && (
|
{useMetaFreeWrapper && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70">Free Wrapper URL</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Wrapper Endpoint</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={metaFreeWrapperUrl}
|
value={metaFreeWrapperUrl}
|
||||||
onChange={(e) => setMetaFreeWrapperUrl(e.target.value)}
|
onChange={(e) => setMetaFreeWrapperUrl(e.target.value)}
|
||||||
placeholder="http://localhost:8000"
|
className="w-full p-3 rounded-xl bg-muted/30 border border-border/50 focus:ring-1 focus:ring-primary/50 outline-none font-mono text-xs"
|
||||||
className="w-full p-2 rounded-lg bg-secondary/30 border border-border/50 focus:ring-1 focus:ring-primary/50 outline-none font-mono text-xs text-white/60"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-white/5">
|
{/* Meta Cookies Fields */}
|
||||||
<p className="text-sm font-medium mb-3 text-amber-400">Authentication Required</p>
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="mb-4">
|
<div className="flex items-center justify-between px-1">
|
||||||
<MobileCookieInstructions />
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Meta.ai Cookies</label>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
setMetaCookies(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Clipboard error', err);
|
||||||
|
alert('Clipboard permission denied.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta AI Cookies */}
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<label className="text-sm font-medium">Meta.ai Cookies</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={metaCookies}
|
value={metaCookies}
|
||||||
onChange={(e) => setMetaCookies(e.target.value)}
|
onChange={(e) => setMetaCookies(e.target.value)}
|
||||||
placeholder="Paste cookies from meta.ai..."
|
placeholder="Paste your Meta cookies..."
|
||||||
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
|
className="w-full h-32 bg-background/50 border border-border/50 rounded-2xl p-4 text-xs font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary/50 outline-none resize-none transition-all"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Get from logged-in <a href="https://www.meta.ai" target="_blank" className="underline hover:text-primary">meta.ai</a> session.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Facebook Cookies */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Facebook.com Cookies <span className="text-red-500">*</span></label>
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Facebook.com Auth Cookies</label>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
setFacebookCookies(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Clipboard error', err);
|
||||||
|
alert('Clipboard permission denied.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={facebookCookies}
|
value={facebookCookies}
|
||||||
onChange={(e) => setFacebookCookies(e.target.value)}
|
onChange={(e) => setFacebookCookies(e.target.value)}
|
||||||
placeholder="Paste cookies from facebook.com (REQUIRED for authentication)..."
|
placeholder="Paste your Facebook cookies (REQUIRED)..."
|
||||||
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
|
className="w-full h-32 bg-background/50 border border-border/50 rounded-2xl p-4 text-xs font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary/50 outline-none resize-none transition-all"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
<strong>Required:</strong> Meta AI authenticates via Facebook. Get from logged-in <a href="https://www.facebook.com" target="_blank" className="underline hover:text-primary">facebook.com</a> session using Cookie-Editor.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<MobileCookieInstructions provider="meta" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{saved ? "Saved!" : "Save Settings"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -202,63 +202,65 @@ export function UploadHistory() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectionMode && (
|
{!selectionMode && (
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex flex-col items-center text-center gap-6 mb-12">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="p-3 bg-secondary rounded-xl text-primary">
|
<div className="p-4 bg-secondary rounded-2xl text-primary shadow-sm">
|
||||||
<Clock className="h-6 w-6" />
|
<Clock className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">Uploads</h2>
|
<h2 className="text-3xl font-black tracking-tight">Uploads</h2>
|
||||||
<p className="text-muted-foreground">Your reference collection.</p>
|
<p className="text-muted-foreground text-sm font-medium">Your reference collection.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
className="flex items-center gap-2 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-destructive hover:bg-destructive/10 rounded-full border border-destructive/20 transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<span>Clear All</span>
|
<span>Clear All History</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<div className="flex items-center gap-2 mb-6 bg-secondary/30 p-1 rounded-xl w-fit">
|
<div className="flex justify-center mb-10 overflow-hidden">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-1.5 bg-secondary/30 p-1.5 rounded-3xl border border-border/50 shadow-soft max-w-full">
|
||||||
{(['all', 'subject', 'scene', 'style', 'videos'] as const).map(cat => (
|
{(['all', 'subject', 'scene', 'style', 'videos'] as const).map(cat => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setFilter(cat)}
|
onClick={() => setFilter(cat)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
|
"px-5 md:px-6 py-2 md:py-2.5 rounded-2xl text-[10px] md:text-xs font-black transition-all capitalize uppercase tracking-widest active:scale-95 whitespace-nowrap",
|
||||||
filter === cat
|
filter === cat
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
{filter === 'videos' ? (
|
{filter === 'videos' ? (
|
||||||
// Video Grid
|
// Video Grid
|
||||||
videos.length === 0 ? (
|
videos.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground p-12 bg-card/50 rounded-3xl border border-dashed border-border">
|
<div className="flex flex-col items-center justify-center text-muted-foreground p-16 bg-card/30 rounded-[2.5rem] border border-dashed border-border/50 max-w-2xl mx-auto">
|
||||||
<div className="p-4 bg-secondary/50 rounded-full mb-4">
|
<div className="p-6 bg-secondary/30 rounded-full mb-6">
|
||||||
<Film className="h-8 w-8 opacity-50" />
|
<Film className="h-10 w-10 opacity-40 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium mb-1">No videos yet</h3>
|
<h3 className="text-xl font-black text-foreground mb-2">No videos yet</h3>
|
||||||
<p className="text-sm text-center max-w-xs">
|
<p className="text-sm text-center font-medium opacity-60">
|
||||||
Generate videos from your gallery images.
|
Generate videos from your gallery images using the primary generator.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
{videos.map((vid) => (
|
{videos.map((vid) => (
|
||||||
<div key={vid.id} className="group relative aspect-video rounded-xl overflow-hidden bg-black border shadow-sm">
|
<div key={vid.id} className="group relative aspect-video rounded-2xl overflow-hidden bg-black border border-border/50 shadow-lg">
|
||||||
<video
|
<video
|
||||||
src={vid.url}
|
src={vid.url}
|
||||||
poster={`data:image/png;base64,${vid.thumbnail}`}
|
poster={`data:image/png;base64,${vid.thumbnail}`}
|
||||||
|
|
@ -266,16 +268,16 @@ export function UploadHistory() {
|
||||||
controls
|
controls
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
<div className="absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-all pointer-events-none group-hover:pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => removeVideo(vid.id)}
|
onClick={() => removeVideo(vid.id)}
|
||||||
className="p-1.5 bg-black/50 hover:bg-destructive text-white rounded-full transition-colors"
|
className="p-2 bg-black/60 hover:bg-destructive text-white rounded-xl backdrop-blur-md transition-all active:scale-90"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/95 via-black/40 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-all pointer-events-none">
|
||||||
<p className="text-white text-xs line-clamp-1">{vid.prompt}</p>
|
<p className="text-white text-[10px] font-medium line-clamp-2 uppercase tracking-tight">{vid.prompt}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -284,13 +286,13 @@ export function UploadHistory() {
|
||||||
) : (
|
) : (
|
||||||
// Image/Uploads Grid (Existing Logic)
|
// Image/Uploads Grid (Existing Logic)
|
||||||
history.length === 0 ? (
|
history.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground p-12 bg-card/50 rounded-3xl border border-dashed border-border">
|
<div className="flex flex-col items-center justify-center text-muted-foreground p-16 bg-card/30 rounded-[2.5rem] border border-dashed border-border/50 max-w-2xl mx-auto">
|
||||||
<div className="p-4 bg-secondary/50 rounded-full mb-4">
|
<div className="p-6 bg-secondary/30 rounded-full mb-6">
|
||||||
<Clock className="h-8 w-8 opacity-50" />
|
<Upload className="h-10 w-10 opacity-40 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium mb-1">No uploads yet</h3>
|
<h3 className="text-xl font-black text-foreground mb-2">No uploads yet</h3>
|
||||||
<p className="text-sm text-center max-w-xs">
|
<p className="text-sm text-center font-medium opacity-60">
|
||||||
Drag and drop images here to upload.
|
Drag and drop images anywhere or use the plus buttons in the creator.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
74
components/theme-provider.tsx
Normal file
74
components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "dark" | "light" | "system";
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultTheme?: Theme;
|
||||||
|
storageKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: "system",
|
||||||
|
setTheme: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = "system",
|
||||||
|
storageKey = "kv-pix-theme",
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (typeof window !== "undefined" ? (localStorage.getItem(storageKey) as Theme) || defaultTheme : defaultTheme)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme);
|
||||||
|
setTheme(theme);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeProviderContext);
|
||||||
|
|
||||||
|
if (context === undefined)
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"last_updated": "2026-01-07T14:09:02.513Z",
|
"last_updated": "2026-01-07T15:48:03.652Z",
|
||||||
"lastSync": 1767794942513,
|
"lastSync": 1767800883652,
|
||||||
"categories": {
|
"categories": {
|
||||||
"style": [
|
"style": [
|
||||||
"Illustration",
|
"Illustration",
|
||||||
|
|
@ -98,7 +98,8 @@
|
||||||
"source": "jimmylv",
|
"source": "jimmylv",
|
||||||
"source_url": "https://github.com/JimmyLv/awesome-nano-banana/tree/main/cases/5",
|
"source_url": "https://github.com/JimmyLv/awesome-nano-banana/tree/main/cases/5",
|
||||||
"createdAt": 1767693873366,
|
"createdAt": 1767693873366,
|
||||||
"useCount": 0
|
"useCount": 1,
|
||||||
|
"lastUsedAt": 1767800243476
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { dirname } from "path";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import { fileURLToPath } from "url";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
...nextVitals,
|
const __dirname = dirname(__filename);
|
||||||
...nextTs,
|
|
||||||
// Override default ignores of eslint-config-next.
|
const compat = new FlatCompat({
|
||||||
globalIgnores([
|
baseDirectory: __dirname,
|
||||||
// Default ignores of eslint-config-next:
|
});
|
||||||
".next/**",
|
|
||||||
"out/**",
|
const eslintConfig = [
|
||||||
"build/**",
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
"next-env.d.ts",
|
];
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue