Compare commits

..

1 commit
v3.2.0 ... main

Author SHA1 Message Date
KV-Pix Bot
6bf9f6e39c release: v2.5.0 - UI enhancements, pagination, and security
Some checks failed
CI / build (18.x) (push) Has been cancelled
CI / build (20.x) (push) Has been cancelled
2026-01-16 22:08:26 +07:00
2350 changed files with 52786 additions and 5531 deletions

View file

@ -1,141 +1,55 @@
# Stage 1: Build Next.js frontend FROM node:18-alpine AS base
FROM node:20-alpine AS frontend-builder
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
# Install dependencies # Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./ COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN npm ci RUN npm ci
# Copy source and build
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build RUN npm run build
# Stage 2: Build Python backend # Production image, copy all the files and run next
FROM python:3.11-slim AS backend-builder FROM base AS runner
WORKDIR /backend
# Install dependencies
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend source
COPY backend/ ./
# Stage 3: Production image with nginx + supervisor
FROM python:3.11-slim AS runner
WORKDIR /app WORKDIR /app
# Install Node.js, nginx, and supervisor ENV NODE_ENV production
RUN apt-get update && apt-get install -y \ # Uncomment the following line in case you want to disable telemetry during runtime.
curl \ # ENV NEXT_TELEMETRY_DISABLED 1
nginx \
supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user RUN addgroup --system --gid 1001 nodejs
RUN groupadd --system --gid 1001 appgroup \ RUN adduser --system --uid 1001 nextjs
&& useradd --system --uid 1001 --gid appgroup appuser
# Copy Next.js standalone build COPY --from=builder /app/public ./public
COPY --from=frontend-builder /app/public ./public
COPY --from=frontend-builder /app/.next/standalone ./
COPY --from=frontend-builder /app/.next/static ./.next/static
# Copy Python backend # Set the correct permission for prerender cache
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages RUN mkdir .next
COPY --from=backend-builder /backend ./backend RUN chown nextjs:nodejs .next
# Copy data directory for prompts # Automatically leverage output traces to reduce image size
COPY --from=frontend-builder /app/data ./data # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Create nginx config - reverse proxy /api to FastAPI USER nextjs
RUN cat > /etc/nginx/sites-available/default <<'EOF'
server {
listen 80;
server_name _;
# Proxy /api/* to FastAPI backend (strip /api prefix) EXPOSE 3000
location /api/ {
proxy_pass http://127.0.0.1:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Proxy everything else to Next.js ENV PORT 3000
location / { ENV HOSTNAME "0.0.0.0"
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
EOF
# Create supervisor config CMD ["node", "server.js"]
RUN mkdir -p /var/log/supervisor
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nextjs]
command=node /app/server.js
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=NODE_ENV=production,PORT=3000,HOSTNAME=0.0.0.0
[program:fastapi]
command=python -m uvicorn main:app --host 0.0.0.0 --port 8000
directory=/app/backend
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
EOF
# Set permissions
RUN chown -R appuser:appgroup /app /var/log/supervisor
# Expose only port 80 (nginx) - internal ports 3000 and 8000 not needed externally
EXPOSE 80
# Health check via nginx
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost/api/health || exit 1
# Run supervisor
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View file

@ -1,56 +1,24 @@
# kv-pix (V3) # kv-pix (V2)
A modern, full-stack AI Image Generator with **FastAPI backend** and **Next.js frontend**. A modern, lightweight AI Image Generator powered by Google ImageFX (Whisk), Grok, and Meta AI. Built with Next.js 14, TypeScript, and Tailwind CSS.
Powered by Google ImageFX (Whisk), Grok, and Meta AI.
## 🆕 What's New in V3
- **FastAPI Backend** - Python backend with automatic Swagger documentation
- **Better Security** - Centralized CORS, Pydantic validation, structured error handling
- **API Documentation** - Interactive docs at `/api/docs` (Swagger) and `/api/redoc`
- **Improved Architecture** - Clean separation of frontend and backend
## 🚀 Quick Start ## 🚀 Quick Start
### Prerequisites ### Prerequisites
- Node.js 18+ - Node.js 18+
- Python 3.11+
- Whisk/Grok/Meta Cookies (from respective services) - Whisk/Grok/Meta Cookies (from respective services)
### Installation ### Installation
```bash ```bash
# Install frontend dependencies
npm install npm install
# Set up backend
cd backend
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
``` ```
### Run Locally ### Run Locally
**Terminal 1 - Backend:**
```bash
cd backend
source venv/bin/activate
uvicorn main:app --reload --port 8000
```
**Terminal 2 - Frontend:**
```bash ```bash
npm run dev npm run dev
# App will be live at http://localhost:3000
``` ```
| Service | URL |
|---------|-----|
| Frontend | http://localhost:3000 |
| Backend API | http://localhost:8000 |
| Swagger UI | http://localhost:8000/docs |
| ReDoc | http://localhost:8000/redoc |
## 🐳 Docker Deployment (Synology NAS / linux/amd64) ## 🐳 Docker Deployment (Synology NAS / linux/amd64)
### Using Docker Compose (Recommended) ### Using Docker Compose (Recommended)
@ -60,53 +28,38 @@ docker-compose up -d
### Using Docker CLI ### Using Docker CLI
```bash ```bash
# Pull from registry # Build
docker pull git.khoavo.myds.me/vndangkhoa/apix:v3 docker build -t kv-pix:latest .
# Run # Run
docker run -d -p 3000:3000 -p 8000:8000 --name kv-pix git.khoavo.myds.me/vndangkhoa/apix:v3 docker run -d -p 3001:3000 --name kv-pix kv-pix:latest
``` ```
### Synology Container Manager
1. Pull the image or build locally.
2. Create a container from the image.
3. Map port `3000` (container) to your desired host port (e.g., `3001`).
4. Start the container.
5. Access the app at `http://<NAS_IP>:3001`.
## 🏗️ Architecture ## 🏗️ Architecture
- **Framework**: [Next.js 14](https://nextjs.org/) (App Router)
``` - **Styling**: [Tailwind CSS](https://tailwindcss.com/) + Custom Components
┌─────────────────┐ HTTP/REST ┌──────────────────┐
│ Next.js UI │ ◄───────────────► │ FastAPI Backend │
│ (Port 3000) │ │ (Port 8000) │
└─────────────────┘ └────────┬─────────┘
┌───────────┴───────────┐
│ │
┌─────▼─────┐ ┌──────▼──────┐
│ WhiskAPI │ │ MetaAI API │
└───────────┘ └─────────────┘
```
### Tech Stack
- **Frontend**: [Next.js 15](https://nextjs.org/) (App Router) + [Tailwind CSS](https://tailwindcss.com/)
- **Backend**: [FastAPI](https://fastapi.tiangolo.com/) + [Pydantic](https://docs.pydantic.dev/)
- **State**: [Zustand](https://github.com/pmndrs/zustand) - **State**: [Zustand](https://github.com/pmndrs/zustand)
- **Icons**: [Lucide React](https://lucide.dev/) - **Icons**: [Lucide React](https://lucide.dev/)
### Project Structure ### Project Structure
``` ```
├── app/ # Next.js pages and (legacy) API routes app/ # Pages and API Routes
├── backend/ # FastAPI Python backend components/ # React UI Components
│ ├── main.py lib/ # Core Logic (whisk-client, meta-client, grok-client)
│ ├── routers/ # API endpoints data/ # Prompt library JSON
│ ├── services/ # Business logic (whisk_client, meta_client) public/ # Static assets
│ └── models/ # Pydantic request/response models
├── components/ # React UI components
├── lib/ # Frontend utilities and API client
├── data/ # Prompt library JSON
└── public/ # Static assets
``` ```
## ✨ Features ## ✨ Features
- **Multi-Provider**: Google Whisk (ImageFX), Grok (xAI), Meta AI (Imagine) - **Multi-Provider**: Google Whisk (ImageFX), Grok (xAI), Meta AI (Imagine)
- **FastAPI Backend**: Type-safe Python API with auto-documentation - **Prompt Library**: Curated prompts organized by categories and sources
- **Prompt Library**: Curated prompts organized by categories
- **Upload History**: Reuse previously uploaded reference images - **Upload History**: Reuse previously uploaded reference images
- **Reference Chips**: Drag-and-drop references for Subject/Scene/Style - **Reference Chips**: Drag-and-drop references for Subject/Scene/Style
- **Video Generation**: Animate images with Whisk Animate (Veo) - **Video Generation**: Animate images with Whisk Animate (Veo)
@ -114,12 +67,11 @@ docker run -d -p 3000:3000 -p 8000:8000 --name kv-pix git.khoavo.myds.me/vndangk
- **Dark Mode**: Material 3 inspired aesthetic - **Dark Mode**: Material 3 inspired aesthetic
## 🍪 Cookie Configuration ## 🍪 Cookie Configuration
1. Go to the respective service: 1. Go to the respective service:
- Whisk: [ImageFX](https://labs.google/fx/tools/image-fx) - Whisk: [ImageFX](https://labs.google/fx/tools/image-fx)
- Grok: [grok.com](https://grok.com) - Grok: [grok.com](https://grok.com)
- Meta: [meta.ai](https://www.meta.ai) - Meta: [meta.ai](https://www.meta.ai)
2. Open DevTools (F12) → Application → Cookies. 2. Open DevTools (F12) -> Application -> Cookies.
3. Copy the cookie string (or use a "Get Cookies" extension to copy as JSON). 3. Copy the cookie string (or use a "Get Cookies" extension to copy as JSON).
4. Paste into the **Settings** menu in the app. 4. Paste into the **Settings** menu in the app.

View file

@ -25,78 +25,50 @@
@layer base { @layer base {
:root { :root {
/* Light Mode - Slate Refresh */ /* Light Mode (from Reference) */
--background: #F8FAFC; --background: #F3F4F6;
/* Slate-50 */ --foreground: #111827;
--foreground: #0F172A;
/* Slate-900 */
--card: #FFFFFF; --card: #FFFFFF;
--card-foreground: #1E293B; --card-foreground: #111827;
/* Slate-800 */ --popover: #FFFFFF;
--popover: rgba(255, 255, 255, 0.8); --popover-foreground: #111827;
--popover-foreground: #0F172A; --primary: #FFD700;
--primary: #7C3AED; --primary-foreground: #111827;
/* Violet-600 */ --secondary: #E5E7EB;
--primary-foreground: #FFFFFF; --secondary-foreground: #111827;
--secondary: #E2E8F0; --muted: #E5E7EB;
/* Slate-200 */ --muted-foreground: #6B7280;
--secondary-foreground: #0F172A; --accent: #FFD700;
--muted: #F1F5F9; --accent-foreground: #111827;
/* Slate-100 */
--muted-foreground: #64748B;
/* Slate-500 */
--accent: #E2E8F0;
--accent-foreground: #0F172A;
--destructive: #EF4444; --destructive: #EF4444;
--destructive-foreground: #FFFFFF; --destructive-foreground: #FEF2F2;
--border: #E2E8F0; --border: #E5E7EB;
/* Slate-200 */ --input: #E5E7EB;
--input: #F1F5F9; --ring: #FFD700;
--ring: rgba(124, 58, 237, 0.4); --radius: 0.5rem;
--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 - Deep Navy Slate */ /* Dark Mode (from Reference) */
--background: #0F172A; --background: #1F2937;
/* Slate-900 */ --foreground: #F9FAFB;
--foreground: #F8FAFC; --card: #374151;
/* Slate-50 */ --card-foreground: #F9FAFB;
--card: #1E293B; --popover: #374151;
/* Slate-800 */ --popover-foreground: #F9FAFB;
--card-foreground: #F1F5F9; --primary: #FFD700;
--popover: rgba(30, 41, 59, 0.8); --primary-foreground: #111827;
--popover-foreground: #F8FAFC; --secondary: #4B5563;
--primary: #8B5CF6; --secondary-foreground: #F9FAFB;
/* Violet-500 (brighter for dark) */ --muted: #4B5563;
--primary-foreground: #FFFFFF; --muted-foreground: #9CA3AF;
--secondary: #334155; --accent: #FFD700;
/* Slate-700 */ --accent-foreground: #111827;
--secondary-foreground: #F8FAFC; --destructive: #EF4444;
--muted: #334155; --destructive-foreground: #FEF2F2;
/* Slate-700 */ --border: #4B5563;
--muted-foreground: #94A3B8; --input: #4B5563;
/* Slate-400 */ --ring: #FFD700;
--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);
} }
* { * {
@ -104,14 +76,13 @@
} }
body { body {
@apply bg-background text-foreground transition-colors duration-300; @apply bg-background text-foreground;
} }
} }
/* Custom Scrollbar - Minimal & Modern */ /* Custom Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 8px;
height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@ -119,32 +90,10 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--muted); background: #374151;
border-radius: 10px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground); background: #4B5563;
}
/* 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;
} }

View file

@ -1,14 +1,16 @@
import type { Metadata } from "next"; 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 { ThemeProvider } from "@/components/theme-provider";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "kv-pix | AI Image Generator", title: "kv-pix | AI Image Generator",
description: "Generate images with Google ImageFX (Whisk)", description: "Generate images with Google ImageFX (Whisk)",
robots: {
index: false,
follow: false,
},
}; };
export default function RootLayout({ export default function RootLayout({
@ -17,13 +19,9 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" className="dark" suppressHydrationWarning>
<body className={inter.className} suppressHydrationWarning> <body className={inter.className} suppressHydrationWarning>
<ThemeProvider defaultTheme="system" storageKey="kv-pix-theme"> {children}
<ErrorBoundary>
{children}
</ErrorBoundary>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View file

@ -1,6 +1,7 @@
"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";
@ -8,8 +9,9 @@ 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();
@ -18,29 +20,17 @@ 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-[100dvh] w-full bg-background text-foreground overflow-hidden font-sans flex-col relative"> <div className="flex h-screen w-full bg-background text-foreground overflow-hidden font-sans flex-col">
{/* Top Navbar - Mobile Header */} {/* Top Navbar */}
<Navbar /> <Navbar />
{/* Main Content Area */} {/* Main Content Area */}
<main className="flex-1 relative flex flex-col w-full overflow-hidden"> <main className="flex-1 relative flex flex-col h-full w-full overflow-hidden mt-16">
{/* Scrollable Container */} {/* Scrollable Container */}
<div className="flex-1 overflow-y-auto w-full scroll-smooth no-scrollbar pt-16 pb-24 md:pb-0"> <div className="flex-1 overflow-y-auto w-full scroll-smooth">
<div className="w-full max-w-lg md:max-w-7xl mx-auto p-4 md:p-6 min-h-full"> <div className="min-h-full w-full max-w-[1600px] mx-auto p-4 md:p-6 pb-20">
{/* Always show Hero on Create View */} {/* Always show Hero on Create View */}
{currentView === 'gallery' && ( {currentView === 'gallery' && (
@ -62,8 +52,6 @@ export default function Home() {
</div> </div>
</main> </main>
{/* Bottom Navigation (Mobile & Desktop App-like) */}
<BottomNav currentTab={getActiveTab()} onTabChange={handleTabChange} />
<CookieExpiredDialog /> <CookieExpiredDialog />
</div> </div>

View file

@ -1,114 +0,0 @@
"""
KV-Pix FastAPI Backend
A secure and intuitive API backend for the KV-Pix image generation application.
Provides endpoints for:
- Whisk image and video generation
- Meta AI image generation
- Prompt library management
- Reference image uploads
- Upload history
API Documentation available at /docs (Swagger UI) and /redoc (ReDoc)
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import sys
from pathlib import Path
# Add backend to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from routers import generate, video, references, meta, prompts, history
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events"""
print("🚀 KV-Pix FastAPI Backend starting...")
print("📚 Swagger UI available at: http://localhost:8000/docs")
print("📖 ReDoc available at: http://localhost:8000/redoc")
yield
print("👋 KV-Pix FastAPI Backend shutting down...")
app = FastAPI(
title="KV-Pix API",
description="""
## KV-Pix Image Generation API
A powerful API for AI image generation using multiple providers.
### Features
- **Whisk API**: Google's experimental image generation with reference images
- **Meta AI**: Meta's Imagine model for creative images
- **Prompt Library**: Curated prompts with categories
- **Upload History**: Track and reuse uploaded references
### Authentication
All generation endpoints require provider-specific cookies passed in the request body.
See the Settings page in the web app for cookie configuration instructions.
""",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc"
)
# CORS middleware - allow Next.js frontend
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(generate.router)
app.include_router(video.router)
app.include_router(references.router)
app.include_router(meta.router)
app.include_router(prompts.router)
app.include_router(history.router)
@app.get("/", tags=["Health"])
async def root():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "kv-pix-api",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health", tags=["Health"])
async def health_check():
"""Detailed health check"""
return {
"status": "healthy",
"endpoints": {
"generate": "/generate",
"video": "/video/generate",
"references": "/references/upload",
"meta": "/meta/generate",
"prompts": "/prompts",
"history": "/history"
},
"documentation": {
"swagger": "/docs",
"redoc": "/redoc"
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)

View file

@ -1,3 +0,0 @@
# Models package
from .requests import *
from .responses import *

View file

@ -1,59 +0,0 @@
"""
Pydantic request models for API validation
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
class GenerateRequest(BaseModel):
"""Request for Whisk image generation"""
prompt: str = Field(..., min_length=1, description="Image generation prompt")
aspectRatio: str = Field(default="1:1", description="Aspect ratio (1:1, 9:16, 16:9, 4:3, 3:4)")
refs: Optional[Dict[str, Any]] = Field(default=None, description="Reference images {subject, scene, style}")
preciseMode: bool = Field(default=False, description="Enable precise mode")
imageCount: int = Field(default=1, ge=1, le=4, description="Number of images to generate")
cookies: Optional[str] = Field(default=None, description="Whisk cookies")
class VideoGenerateRequest(BaseModel):
"""Request for Whisk video generation"""
prompt: str = Field(..., min_length=1)
imageBase64: Optional[str] = Field(default=None, description="Base64 image data")
imageGenerationId: Optional[str] = Field(default=None, description="Existing image ID")
cookies: Optional[str] = None
class ReferenceUploadRequest(BaseModel):
"""Request for uploading reference image"""
imageBase64: str = Field(..., description="Base64 encoded image")
mimeType: str = Field(..., description="Image MIME type (image/jpeg, image/png, etc.)")
category: str = Field(..., description="Reference category (subject, scene, style)")
cookies: str = Field(..., description="Whisk cookies")
class MetaGenerateRequest(BaseModel):
"""Request for Meta AI image generation"""
prompt: str = Field(..., min_length=1)
cookies: Optional[str] = Field(default=None, description="Meta AI cookies")
imageCount: int = Field(default=4, ge=1, le=4)
aspectRatio: str = Field(default="portrait", description="portrait, landscape, square")
useMetaFreeWrapper: bool = Field(default=False)
metaFreeWrapperUrl: Optional[str] = Field(default="http://localhost:8000")
class MetaVideoRequest(BaseModel):
"""Request for Meta AI video generation"""
prompt: str = Field(..., min_length=1)
cookies: str = Field(..., description="Meta AI cookies")
aspectRatio: str = Field(default="portrait")
class PromptUseRequest(BaseModel):
"""Track prompt usage"""
promptId: int = Field(..., description="Prompt ID to track")
class PromptUploadRequest(BaseModel):
"""Upload prompt thumbnail"""
promptId: int
imageBase64: str

View file

@ -1,113 +0,0 @@
"""
Pydantic response models
"""
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
class GeneratedImage(BaseModel):
"""Single generated image"""
data: str # base64
index: Optional[int] = None
prompt: str
aspectRatio: str
class GenerateResponse(BaseModel):
"""Response from image generation"""
images: List[GeneratedImage]
class VideoResponse(BaseModel):
"""Response from video generation"""
success: bool
id: Optional[str] = None
url: Optional[str] = None
status: Optional[str] = None
class ReferenceUploadResponse(BaseModel):
"""Response from reference upload"""
success: bool
id: str
class MetaImageResult(BaseModel):
"""Meta AI generated image"""
data: Optional[str] = None
url: Optional[str] = None
prompt: str
model: str
aspectRatio: str = "1:1"
class MetaGenerateResponse(BaseModel):
"""Response from Meta AI generation"""
success: bool
images: List[MetaImageResult]
class MetaVideoResult(BaseModel):
"""Meta AI video result"""
url: str
prompt: str
class MetaVideoResponse(BaseModel):
"""Response from Meta AI video generation"""
success: bool
videos: List[MetaVideoResult]
conversation_id: Optional[str] = None
class Prompt(BaseModel):
"""Prompt library item"""
id: int
title: str
description: str
prompt: str
category: str
source: str
source_url: str
images: Optional[List[str]] = None
useCount: int = 0
lastUsedAt: Optional[int] = None
createdAt: Optional[int] = None
class PromptCache(BaseModel):
"""Prompt library cache"""
prompts: List[Prompt]
last_updated: Optional[str] = None
lastSync: Optional[int] = None
categories: Dict[str, List[str]] = {}
total_count: int = 0
sources: List[str] = []
class SyncResponse(BaseModel):
"""Response from prompt sync"""
success: bool
count: int
added: int
class HistoryItem(BaseModel):
"""Upload history item"""
id: str
url: str
originalName: str
category: str
mediaId: Optional[str] = None
createdAt: Optional[int] = None
class HistoryResponse(BaseModel):
"""Response from history endpoint"""
history: List[HistoryItem]
class ErrorResponse(BaseModel):
"""Standard error response"""
error: str
details: Optional[str] = None

View file

@ -1,6 +0,0 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
httpx>=0.26.0
pydantic>=2.5.0
python-multipart>=0.0.6
aiofiles>=23.2.0

View file

@ -1 +0,0 @@
# Routers package

View file

@ -1,92 +0,0 @@
"""
Generate Router - Whisk image generation
"""
from fastapi import APIRouter, HTTPException
from models.requests import GenerateRequest
from models.responses import GenerateResponse, GeneratedImage, ErrorResponse
from services.whisk_client import WhiskClient
import asyncio
router = APIRouter(tags=["Generate"])
@router.post(
"/generate",
response_model=GenerateResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def generate_images(request: GenerateRequest):
"""
Generate images using Whisk API.
- **prompt**: Text description of the image to generate
- **aspectRatio**: Output aspect ratio (1:1, 9:16, 16:9, etc.)
- **refs**: Optional reference images {subject, scene, style}
- **imageCount**: Number of parallel generation requests (1-4)
- **cookies**: Whisk authentication cookies
"""
if not request.cookies:
raise HTTPException(status_code=401, detail="Whisk cookies not found. Please configure settings.")
try:
# Normalize cookies if JSON format
cookie_string = request.cookies.strip()
if cookie_string.startswith('[') or cookie_string.startswith('{'):
import json
try:
cookie_array = json.loads(cookie_string)
if isinstance(cookie_array, list):
cookie_string = "; ".join(
f"{c['name']}={c['value']}" for c in cookie_array
)
print(f"[Generate] Parsed {len(cookie_array)} cookies from JSON.")
except Exception as e:
print(f"[Generate] Failed to parse cookie JSON: {e}")
client = WhiskClient(cookie_string)
# Generate images in parallel if imageCount > 1
parallel_count = min(max(1, request.imageCount), 4)
print(f"Starting {parallel_count} parallel generation requests for prompt: \"{request.prompt[:20]}...\"")
async def single_generate():
try:
return await client.generate(
request.prompt,
request.aspectRatio,
request.refs,
request.preciseMode
)
except Exception as e:
print(f"Single generation request failed: {e}")
return []
results = await asyncio.gather(*[single_generate() for _ in range(parallel_count)])
all_images = [img for result in results for img in result]
if not all_images:
raise HTTPException(status_code=500, detail="All generation requests failed. Check logs or try again.")
return GenerateResponse(
images=[
GeneratedImage(
data=img.data,
index=img.index,
prompt=img.prompt,
aspectRatio=img.aspect_ratio
)
for img in all_images
]
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
msg = str(e)
is_auth_error = any(x in msg.lower() for x in ["401", "403", "auth", "cookies", "expired"])
status_code = 401 if is_auth_error else 500
raise HTTPException(status_code=status_code, detail=msg)

View file

@ -1,164 +0,0 @@
"""
History Router - Upload history management
Note: This is a simplified version. The original Next.js version
stores history in memory/file. For FastAPI, we'll use a simple
JSON file approach similar to prompts.
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from models.responses import HistoryResponse, HistoryItem, ErrorResponse
from services.whisk_client import WhiskClient
from pathlib import Path
import json
import uuid
import base64
from typing import Optional
from datetime import datetime
router = APIRouter(prefix="/history", tags=["History"])
# History storage
HISTORY_FILE = Path(__file__).parent.parent.parent / "data" / "history.json"
def load_history() -> list:
"""Load history from JSON file"""
try:
if HISTORY_FILE.exists():
return json.loads(HISTORY_FILE.read_text())
except:
pass
return []
def save_history(history: list) -> None:
"""Save history to JSON file"""
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.write_text(json.dumps(history, indent=2))
@router.get(
"",
response_model=HistoryResponse,
responses={500: {"model": ErrorResponse}}
)
async def get_history(category: Optional[str] = None):
"""
Get upload history.
- **category**: Optional filter by category (subject, scene, style)
"""
try:
history = load_history()
if category:
history = [h for h in history if h.get("category") == category]
return HistoryResponse(history=[HistoryItem(**h) for h in history])
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"",
response_model=HistoryItem,
responses={
400: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def upload_to_history(
file: UploadFile = File(...),
category: str = Form(default="subject"),
cookies: Optional[str] = Form(default=None)
):
"""
Upload an image to history.
- **file**: Image file to upload
- **category**: Category (subject, scene, style)
- **cookies**: Optional Whisk cookies to also upload to Whisk
"""
try:
# Read file
content = await file.read()
base64_data = base64.b64encode(content).decode('utf-8')
mime_type = file.content_type or 'image/png'
# Upload to Whisk if cookies provided
media_id = None
if cookies:
try:
client = WhiskClient(cookies)
media_id = await client.upload_reference_image(base64_data, mime_type, category)
except Exception as e:
print(f"[History] Whisk upload failed: {e}")
# Create history item
new_item = {
"id": str(uuid.uuid4()),
"url": f"data:{mime_type};base64,{base64_data}",
"originalName": file.filename or "upload.png",
"category": category,
"mediaId": media_id,
"createdAt": int(datetime.now().timestamp() * 1000)
}
# Save to history
history = load_history()
history.insert(0, new_item) # Add to beginning
save_history(history)
return HistoryItem(**new_item)
except Exception as e:
print(f"Upload error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"/{item_id}",
responses={
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def delete_history_item(item_id: str):
"""
Delete an item from history.
- **item_id**: ID of the history item to delete
"""
try:
history = load_history()
original_len = len(history)
history = [h for h in history if h.get("id") != item_id]
if len(history) == original_len:
raise HTTPException(status_code=404, detail="Item not found")
save_history(history)
return {"success": True, "deleted": item_id}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"",
responses={500: {"model": ErrorResponse}}
)
async def clear_history():
"""
Clear all history items.
"""
try:
save_history([])
return {"success": True, "message": "History cleared"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -1,150 +0,0 @@
"""
Meta AI Router - Meta AI image and video generation
"""
from fastapi import APIRouter, HTTPException
from models.requests import MetaGenerateRequest, MetaVideoRequest
from models.responses import MetaGenerateResponse, MetaImageResult, MetaVideoResponse, MetaVideoResult, ErrorResponse
from services.meta_client import MetaAIClient
import json
router = APIRouter(prefix="/meta", tags=["Meta AI"])
@router.post(
"/generate",
response_model=MetaGenerateResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
422: {"model": ErrorResponse}
}
)
async def meta_generate(request: MetaGenerateRequest):
"""
Generate images using Meta AI's Imagine model.
- **prompt**: Text description of the image to generate
- **cookies**: Meta AI cookies (optional if using free wrapper)
- **imageCount**: Number of images to generate (1-4)
- **aspectRatio**: portrait, landscape, or square
- **useMetaFreeWrapper**: Use free API wrapper instead of direct Meta AI
- **metaFreeWrapperUrl**: URL of the free wrapper service
"""
# Only check for cookies if NOT using free wrapper
if not request.useMetaFreeWrapper and not request.cookies:
raise HTTPException(
status_code=401,
detail="Meta AI cookies required. Configure in Settings or use Free Wrapper."
)
print(f"[Meta AI Route] Generating images for: \"{request.prompt[:30]}...\" ({request.aspectRatio})")
# Diagnostic: Check cookie count
if request.cookies:
try:
cookies = request.cookies.strip()
if cookies.startswith('['):
parsed = json.loads(cookies)
count = len(parsed) if isinstance(parsed, list) else 0
else:
count = len(cookies.split(';'))
print(f"[Meta AI Route] Received {count} cookies (Free Wrapper: {request.useMetaFreeWrapper})")
except:
print(f"[Meta AI Route] Cookie format: {type(request.cookies)}")
try:
client = MetaAIClient(
cookies=request.cookies or "",
use_free_wrapper=request.useMetaFreeWrapper,
free_wrapper_url=request.metaFreeWrapperUrl or "http://localhost:8000"
)
results = await client.generate(
request.prompt,
request.imageCount,
request.aspectRatio
)
# Download images as base64 for storage
images = []
for img in results:
base64_data = img.data
if not base64_data and img.url:
try:
base64_data = await client.download_as_base64(img.url)
except Exception as e:
print(f"[Meta AI Route] Failed to download image: {e}")
images.append(MetaImageResult(
data=base64_data or "",
url=img.url,
prompt=img.prompt,
model=img.model,
aspectRatio="1:1"
))
valid_images = [img for img in images if img.data or img.url]
if not valid_images:
raise HTTPException(status_code=422, detail="No valid images generated")
return MetaGenerateResponse(success=True, images=valid_images)
except Exception as e:
print(f"[Meta AI Route] Error: {e}")
raise HTTPException(status_code=422, detail=str(e))
@router.post(
"/video",
response_model=MetaVideoResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def meta_video(request: MetaVideoRequest):
"""
Generate video from text prompt using Meta AI's Kadabra engine.
- **prompt**: Text description for video generation
- **cookies**: Meta AI cookies
- **aspectRatio**: portrait, landscape, or square
"""
if not request.cookies:
raise HTTPException(
status_code=401,
detail="Meta AI cookies required for video generation"
)
print(f"[Meta Video Route] Generating video for: \"{request.prompt[:30]}...\"")
try:
from services.meta_video_client import MetaVideoClient
client = MetaVideoClient(cookies=request.cookies)
results = await client.generate_video(
prompt=request.prompt,
wait_before_poll=10,
max_attempts=30,
poll_interval=5
)
videos = [
MetaVideoResult(
url=r.url,
prompt=r.prompt,
model="meta-kadabra"
) for r in results
]
return MetaVideoResponse(success=True, videos=videos)
except Exception as e:
print(f"[Meta Video Route] Error: {e}")
error_message = str(e)
if "expired" in error_message.lower() or "invalid" in error_message.lower():
raise HTTPException(status_code=401, detail=error_message)
raise HTTPException(status_code=500, detail=error_message)

View file

@ -1,177 +0,0 @@
"""
Prompts Router - Prompt library management
"""
from fastapi import APIRouter, HTTPException
from models.requests import PromptUseRequest, PromptUploadRequest
from models.responses import PromptCache, SyncResponse, ErrorResponse
from services.prompts_service import (
get_prompts,
sync_prompts,
track_prompt_use,
upload_prompt_image
)
router = APIRouter(prefix="/prompts", tags=["Prompts"])
@router.get(
"",
response_model=PromptCache,
responses={500: {"model": ErrorResponse}}
)
async def list_prompts():
"""
Get all prompts from the library.
Returns cached prompts with metadata including:
- All prompts with titles, descriptions, and content
- Categories and sources
- Last sync timestamp
Triggers background sync if last sync was more than 1 hour ago.
"""
try:
cache = await get_prompts()
# Lazy Auto-Crawl: Check if sync is needed (every 1 hour)
ONE_HOUR = 60 * 60 * 1000
last_sync = cache.last_sync or 0
import time
if int(time.time() * 1000) - last_sync > ONE_HOUR:
print("[Auto-Crawl] Triggering background sync...")
# Fire and forget - don't await
import asyncio
asyncio.create_task(sync_prompts())
return cache.to_dict()
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to load prompts")
@router.post(
"/sync",
response_model=SyncResponse,
responses={500: {"model": ErrorResponse}}
)
async def sync_prompts_endpoint():
"""
Manually trigger a sync of prompts from all sources.
Crawls prompt sources and merges with existing prompts.
Returns count of total and newly added prompts.
"""
try:
result = await sync_prompts()
return SyncResponse(**result)
except Exception as e:
print(f"Sync failed: {e}")
raise HTTPException(status_code=500, detail="Sync failed")
@router.post(
"/use",
responses={
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def use_prompt(request: PromptUseRequest):
"""
Track usage of a prompt.
Increments the use count and updates lastUsedAt timestamp.
"""
try:
prompt = await track_prompt_use(request.promptId)
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
return {
"success": True,
"promptId": prompt.id,
"useCount": prompt.use_count,
"lastUsedAt": prompt.last_used_at
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/upload",
responses={
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def upload_image(request: PromptUploadRequest):
"""
Upload a thumbnail image for a prompt.
Stores the base64 image data with the prompt.
"""
try:
prompt = await upload_prompt_image(request.promptId, request.imageBase64)
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
return {
"success": True,
"promptId": prompt.id,
"imageCount": len(prompt.images) if prompt.images else 0
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/generate",
responses={
400: {"model": ErrorResponse},
404: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def generate_from_prompt(
promptId: int,
aspectRatio: str = "1:1",
cookies: str = None
):
"""
Generate images using a prompt from the library.
This is a convenience endpoint that:
1. Fetches the prompt by ID
2. Calls the generate endpoint with the prompt content
"""
try:
cache = await get_prompts()
# Find the prompt
prompt = None
for p in cache.prompts:
if p.id == promptId:
prompt = p
break
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
# Track usage
await track_prompt_use(promptId)
# Return prompt info for frontend to generate
return {
"success": True,
"prompt": prompt.to_dict()
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -1,92 +0,0 @@
"""
References Router - Reference image upload
"""
from fastapi import APIRouter, HTTPException
from models.requests import ReferenceUploadRequest
from models.responses import ReferenceUploadResponse, ErrorResponse
from services.whisk_client import WhiskClient
import json
router = APIRouter(tags=["References"])
@router.post(
"/references/upload",
response_model=ReferenceUploadResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def upload_reference(request: ReferenceUploadRequest):
"""
Upload a reference image for Whisk generation.
- **imageBase64**: Base64 encoded image data
- **mimeType**: Image MIME type (image/jpeg, image/png, image/webp, image/gif)
- **category**: Reference category (subject, scene, style)
- **cookies**: Whisk authentication cookies
"""
# Validate MIME type
allowed_types = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if request.mimeType not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {request.mimeType}. Please use JPG, PNG, or WEBP."
)
# Normalize cookies
valid_cookies = request.cookies.strip()
is_json = False
trimmed = valid_cookies.strip()
if trimmed.startswith('[') or trimmed.startswith('{'):
is_json = True
try:
cookie_array = json.loads(trimmed)
if isinstance(cookie_array, list):
valid_cookies = "; ".join(
f"{c['name']}={c['value']}" for c in cookie_array
)
print(f"[API] Successfully parsed {len(cookie_array)} cookies from JSON.")
elif isinstance(cookie_array, dict) and 'name' in cookie_array and 'value' in cookie_array:
valid_cookies = f"{cookie_array['name']}={cookie_array['value']}"
except Exception as e:
print(f"[API] Failed to parse cookie JSON, falling back to raw value: {e}")
# Validate cookie format
if '=' not in valid_cookies:
raise HTTPException(
status_code=400,
detail='Invalid Cookie Format. Cookies must be in "name=value" format or a JSON list.'
)
print(f"[API] Uploading reference image ({request.category}, {request.mimeType})...")
print(f"[API] Using cookies (first 100 chars): {valid_cookies[:100]}...")
print(f"[API] Cookie was JSON: {is_json}")
try:
client = WhiskClient(valid_cookies)
# Remove data URI header if present
raw_base64 = request.imageBase64
if raw_base64.startswith('data:'):
raw_base64 = raw_base64.split(',', 1)[1] if ',' in raw_base64 else raw_base64
media_id = await client.upload_reference_image(
raw_base64,
request.mimeType,
request.category
)
if not media_id:
raise HTTPException(status_code=500, detail="Upload returned no ID")
return ReferenceUploadResponse(success=True, id=media_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
print(f"Reference Upload API failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -1,55 +0,0 @@
"""
Video Router - Whisk video generation
"""
from fastapi import APIRouter, HTTPException
from models.requests import VideoGenerateRequest
from models.responses import VideoResponse, ErrorResponse
from services.whisk_client import WhiskClient
router = APIRouter(tags=["Video"])
@router.post(
"/video/generate",
response_model=VideoResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
500: {"model": ErrorResponse}
}
)
async def generate_video(request: VideoGenerateRequest):
"""
Generate video from an image using Whisk Animate (Veo).
- **prompt**: Motion description for the video
- **imageBase64**: Base64 encoded source image (optional if imageGenerationId provided)
- **imageGenerationId**: Existing Whisk image ID (optional if imageBase64 provided)
- **cookies**: Whisk authentication cookies
"""
if not request.cookies:
raise HTTPException(status_code=401, detail="Whisk cookies not found. Please configure settings.")
try:
client = WhiskClient(request.cookies)
print(f"[Video API] Generating video for prompt: \"{request.prompt[:50]}...\"")
result = await client.generate_video(
request.imageGenerationId or "",
request.prompt,
request.imageBase64
)
return VideoResponse(
success=True,
id=result.id,
url=result.url,
status=result.status
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
print(f"Video Generation API failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -1 +0,0 @@
# Services package

View file

@ -1,525 +0,0 @@
"""
Meta AI Client for Python/FastAPI
Port of lib/providers/meta-client.ts
Handles:
- Session initialization from meta.ai
- GraphQL mutation for image generation (Abra)
- Streaming response parsing
- Polling for async results
- Free wrapper fallback support
"""
import httpx
import json
import uuid
import re
import asyncio
from typing import Optional, Dict, List, Any
META_AI_BASE = "https://www.meta.ai"
GRAPHQL_ENDPOINT = f"{META_AI_BASE}/api/graphql/"
# Orientation mapping
ORIENTATION_MAP = {
"portrait": "VERTICAL",
"landscape": "HORIZONTAL",
"square": "SQUARE"
}
class MetaImageResult:
def __init__(self, url: str, data: Optional[str], prompt: str, model: str):
self.url = url
self.data = data
self.prompt = prompt
self.model = model
def to_dict(self) -> Dict[str, Any]:
return {
"url": self.url,
"data": self.data,
"prompt": self.prompt,
"model": self.model,
"aspectRatio": "1:1"
}
class MetaSession:
def __init__(self):
self.lsd: Optional[str] = None
self.fb_dtsg: Optional[str] = None
self.access_token: Optional[str] = None
self.external_conversation_id: Optional[str] = None
class MetaAIClient:
def __init__(
self,
cookies: str,
use_free_wrapper: bool = True,
free_wrapper_url: str = "http://localhost:8000"
):
self.cookies = self._normalize_cookies(cookies)
self.session = MetaSession()
self.use_free_wrapper = use_free_wrapper
self.free_wrapper_url = free_wrapper_url
if self.cookies:
self._parse_session_from_cookies()
def _normalize_cookies(self, cookies: str) -> str:
"""Normalize cookies from JSON array to string format"""
if not cookies:
return ""
try:
trimmed = cookies.strip()
if trimmed.startswith('['):
parsed = json.loads(trimmed)
if isinstance(parsed, list):
return "; ".join(
f"{c['name']}={c['value']}" for c in parsed
if isinstance(c, dict) and 'name' in c and 'value' in c
)
except (json.JSONDecodeError, KeyError):
pass
return cookies
def _parse_session_from_cookies(self) -> None:
"""Extract session tokens from cookies"""
lsd_match = re.search(r'lsd=([^;]+)', self.cookies)
if lsd_match:
self.session.lsd = lsd_match.group(1)
dtsg_match = re.search(r'fb_dtsg=([^;]+)', self.cookies)
if dtsg_match:
self.session.fb_dtsg = dtsg_match.group(1)
def _parse_cookies_to_dict(self, cookie_str: str) -> Dict[str, str]:
"""Parse cookie string to dictionary"""
result = {}
if not cookie_str:
return result
for pair in cookie_str.split(';'):
pair = pair.strip()
if '=' in pair:
key, _, value = pair.partition('=')
result[key.strip()] = value.strip()
return result
async def get_session(self) -> MetaSession:
"""Get initialized session tokens"""
if not self.use_free_wrapper and not self.session.lsd and not self.session.fb_dtsg:
await self._init_session()
return self.session
def get_cookies(self) -> str:
return self.cookies
async def generate(
self,
prompt: str,
num_images: int = 4,
aspect_ratio: str = "portrait"
) -> List[MetaImageResult]:
"""Generate images using Meta AI's Imagine model"""
print(f"[Meta AI] Generating images for: \"{prompt[:50]}...\" ({aspect_ratio})")
if self.use_free_wrapper:
return await self._generate_with_free_wrapper(prompt, num_images)
# Initialize session if needed
if not self.session.access_token:
await self._init_session()
# Use "Imagine" prefix for image generation
image_prompt = prompt if prompt.lower().startswith('imagine') else f"Imagine {prompt}"
# Send the prompt via GraphQL
response = await self._send_prompt(image_prompt, aspect_ratio)
# Extract images
images = self._extract_images(response, prompt)
if not images:
print("[Meta AI] No images in initial response, polling...")
images = await self._poll_for_images(response, prompt)
return images
async def _generate_with_free_wrapper(
self,
prompt: str,
num_images: int
) -> List[MetaImageResult]:
"""Generate using free API wrapper"""
print(f"[Meta Wrapper] Generating for: \"{prompt[:50]}...\" via {self.free_wrapper_url}")
cookie_dict = self._parse_cookies_to_dict(self.cookies)
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{self.free_wrapper_url}/chat",
headers={"Content-Type": "application/json"},
json={
"message": f"Imagine {prompt}",
"stream": False,
"cookies": cookie_dict
}
)
if response.status_code != 200:
error_text = response.text[:200]
raise Exception(f"Meta Wrapper Error: {response.status_code} - {error_text}")
data = response.json()
images: List[MetaImageResult] = []
# Check for media in response
if data.get("media") and isinstance(data["media"], list):
for m in data["media"]:
if m.get("url"):
images.append(MetaImageResult(
url=m["url"],
data=None,
prompt=prompt,
model="meta-wrapper"
))
# Fallback checks
if not images and data.get("images") and isinstance(data["images"], list):
for url in data["images"]:
images.append(MetaImageResult(
url=url,
data=None,
prompt=prompt,
model="meta-wrapper"
))
if not images:
raise Exception("Meta Wrapper returned no images")
return images
async def _init_session(self) -> None:
"""Initialize session - get access token from meta.ai page"""
print("[Meta AI] Initializing session...")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
META_AI_BASE,
headers={
"Cookie": self.cookies,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept-Language": "en-US,en;q=0.9"
}
)
html = response.text
# Extract access token
token_match = re.search(r'"accessToken":"([^"]+)"', html)
if not token_match:
token_match = re.search(r'accessToken["\']\\s*:\\s*["\']([^"\']+)["\']', html)
# Extract LSD token
lsd_match = (
re.search(r'"LSD",\[\],\{"token":"([^"]+)"', html) or
re.search(r'"lsd":"([^"]+)"', html) or
re.search(r'name="lsd" value="([^"]+)"', html)
)
if lsd_match:
self.session.lsd = lsd_match.group(1)
# Extract DTSG token
dtsg_match = (
re.search(r'"DTSGInitialData".*?"token":"([^"]+)"', html) or
re.search(r'"token":"([^"]+)"', html)
)
if dtsg_match:
self.session.fb_dtsg = dtsg_match.group(1)
if token_match:
self.session.access_token = token_match.group(1)
print("[Meta AI] Got access token")
elif 'login_form' in html or 'login_page' in html:
raise Exception("Meta AI: Cookies expired or invalid")
else:
print("[Meta AI] Warning: Failed to extract access token")
async def _send_prompt(self, prompt: str, aspect_ratio: str = "portrait") -> Any:
"""Send prompt via GraphQL mutation"""
external_conversation_id = str(uuid.uuid4())
timestamp = int(asyncio.get_event_loop().time() * 1000)
random_part = int(str(uuid.uuid4().int)[:7])
offline_threading_id = str((timestamp << 22) | random_part)
self.session.external_conversation_id = external_conversation_id
orientation = ORIENTATION_MAP.get(aspect_ratio, "VERTICAL")
variables = {
"message": {
"sensitive_string_value": prompt
},
"externalConversationId": external_conversation_id,
"offlineThreadingId": offline_threading_id,
"suggestedPromptIndex": None,
"flashVideoRecapInput": {"images": []},
"flashPreviewInput": None,
"promptPrefix": None,
"entrypoint": "ABRA__CHAT__TEXT",
"icebreaker_type": "TEXT",
"imagineClientOptions": {"orientation": orientation},
"__relay_internal__pv__AbraDebugDevOnlyrelayprovider": False,
"__relay_internal__pv__WebPixelRatiorelayprovider": 1
}
body = {
"fb_api_caller_class": "RelayModern",
"fb_api_req_friendly_name": "useAbraSendMessageMutation",
"variables": json.dumps(variables),
"server_timestamps": "true",
"doc_id": "7783822248314888"
}
if self.session.lsd:
body["lsd"] = self.session.lsd
if self.session.fb_dtsg:
body["fb_dtsg"] = self.session.fb_dtsg
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": self.cookies,
"Origin": META_AI_BASE,
"Referer": f"{META_AI_BASE}/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
if self.session.access_token:
headers["Authorization"] = f"OAuth {self.session.access_token}"
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
GRAPHQL_ENDPOINT,
headers=headers,
data=body
)
raw_text = response.text
if response.status_code != 200:
raise Exception(f"Meta AI Error: {response.status_code} - {raw_text[:500]}")
# Parse streaming response
last_valid_response = None
for line in raw_text.split('\n'):
if not line.strip():
continue
try:
parsed = json.loads(line)
streaming_state = (
parsed.get("data", {})
.get("xfb_abra_send_message", {})
.get("bot_response_message", {})
.get("streaming_state")
)
if streaming_state == "OVERALL_DONE":
last_valid_response = parsed
break
# Check for imagine_card
imagine_card = (
parsed.get("data", {})
.get("xfb_abra_send_message", {})
.get("bot_response_message", {})
.get("imagine_card")
)
if imagine_card and imagine_card.get("session", {}).get("media_sets"):
last_valid_response = parsed
except json.JSONDecodeError:
continue
if not last_valid_response:
if "login_form" in raw_text or "facebook.com/login" in raw_text:
raise Exception("Meta AI: Session expired. Please refresh cookies.")
raise Exception("Meta AI: No valid response found")
return last_valid_response
def _extract_images(self, response: Any, original_prompt: str) -> List[MetaImageResult]:
"""Extract image URLs from Meta AI response"""
images: List[MetaImageResult] = []
message_data = (
response.get("data", {})
.get("xfb_abra_send_message", {})
.get("bot_response_message")
)
if message_data:
images.extend(self._extract_images_from_message(message_data, original_prompt))
# Recursive search fallback
if not images and response.get("data"):
print("[Meta AI] Structured extraction failed, doing recursive search...")
found_urls = self._recursive_search_for_images(response["data"])
for url in found_urls:
images.append(MetaImageResult(
url=url,
data=None,
prompt=original_prompt,
model="meta"
))
return images
def _extract_images_from_message(
self,
message_data: Dict,
original_prompt: str
) -> List[MetaImageResult]:
"""Helper to extract images from a single message node"""
images: List[MetaImageResult] = []
imagine_card = message_data.get("imagine_card")
if imagine_card and imagine_card.get("session", {}).get("media_sets"):
for media_set in imagine_card["session"]["media_sets"]:
imagine_media = media_set.get("imagine_media", [])
for media in imagine_media:
url = media.get("uri") or media.get("image_uri")
if url:
images.append(MetaImageResult(
url=url,
data=None,
prompt=original_prompt,
model="meta"
))
# Check attachments
attachments = message_data.get("attachments", [])
for attachment in attachments:
media = attachment.get("media", {})
url = media.get("image_uri") or media.get("uri")
if url:
images.append(MetaImageResult(
url=url,
data=None,
prompt=original_prompt,
model="meta"
))
return images
def _recursive_search_for_images(
self,
obj: Any,
found: Optional[set] = None
) -> List[str]:
"""Recursive search for image-like URLs"""
if found is None:
found = set()
if not obj or not isinstance(obj, (dict, list)):
return []
if isinstance(obj, dict):
for key, val in obj.items():
if isinstance(val, str):
if ('fbcdn.net' in val or 'meta.ai' in val) and \
any(ext in val for ext in ['.jpg', '.png', '.webp', 'image_uri=', '/imagine/']):
found.add(val)
elif isinstance(val, (dict, list)):
self._recursive_search_for_images(val, found)
elif isinstance(obj, list):
for item in obj:
self._recursive_search_for_images(item, found)
return list(found)
async def _poll_for_images(
self,
initial_response: Any,
prompt: str
) -> List[MetaImageResult]:
"""Poll for image generation completion"""
conversation_id = (
initial_response.get("data", {})
.get("node", {})
.get("external_conversation_id")
)
if not conversation_id:
return []
max_attempts = 30
poll_interval = 2
for attempt in range(max_attempts):
print(f"[Meta AI] Polling attempt {attempt + 1}/{max_attempts}...")
await asyncio.sleep(poll_interval)
variables = {"external_conversation_id": conversation_id}
body = {
"fb_api_caller_class": "RelayModern",
"fb_api_req_friendly_name": "KadabraPromptRootQuery",
"variables": json.dumps(variables),
"doc_id": "25290569913909283"
}
if self.session.lsd:
body["lsd"] = self.session.lsd
if self.session.fb_dtsg:
body["fb_dtsg"] = self.session.fb_dtsg
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": self.cookies,
"Origin": META_AI_BASE
}
if self.session.access_token:
headers["Authorization"] = f"OAuth {self.session.access_token}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
GRAPHQL_ENDPOINT,
headers=headers,
data=body
)
data = response.json()
images = self._extract_images(data, prompt)
if images:
print(f"[Meta AI] Got {len(images)} image(s) after polling!")
return images
status = data.get("data", {}).get("kadabra_prompt", {}).get("status")
if status in ["FAILED", "ERROR"]:
break
except Exception as e:
print(f"[Meta AI] Poll error: {e}")
return []
async def download_as_base64(self, url: str) -> str:
"""Download image from URL and convert to base64"""
import base64
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
url,
headers={
"Cookie": self.cookies,
"Referer": META_AI_BASE
}
)
return base64.b64encode(response.content).decode('utf-8')

View file

@ -1,432 +0,0 @@
"""
Meta AI Video Generation Client for FastAPI
Ported from services/metaai-api/src/metaai_api/video_generation.py
Integrated directly into the FastAPI backend to eliminate separate service.
"""
import httpx
import json
import time
import uuid
import re
import asyncio
from typing import Dict, List, Optional, Any
GRAPHQL_URL = "https://www.meta.ai/api/graphql/"
META_AI_BASE = "https://www.meta.ai"
class MetaVideoResult:
def __init__(self, url: str, prompt: str, conversation_id: str):
self.url = url
self.prompt = prompt
self.conversation_id = conversation_id
def to_dict(self) -> Dict[str, Any]:
return {
"url": self.url,
"prompt": self.prompt,
"conversation_id": self.conversation_id
}
class MetaVideoClient:
"""
Async client for Meta AI video generation.
Handles session tokens, video creation requests, and polling for results.
"""
def __init__(self, cookies: str):
"""
Initialize the video client with cookies.
Args:
cookies: Cookie string or JSON array of cookies
"""
self.cookies_str = self._normalize_cookies(cookies)
self.cookies_dict = self._parse_cookies(self.cookies_str)
self.lsd: Optional[str] = None
self.fb_dtsg: Optional[str] = None
def _normalize_cookies(self, cookies: str) -> str:
"""Normalize cookies from JSON array to string format"""
if not cookies:
return ""
try:
trimmed = cookies.strip()
if trimmed.startswith('['):
parsed = json.loads(trimmed)
if isinstance(parsed, list):
return "; ".join(
f"{c['name']}={c['value']}" for c in parsed
if isinstance(c, dict) and 'name' in c and 'value' in c
)
except (json.JSONDecodeError, KeyError):
pass
return cookies
def _parse_cookies(self, cookie_str: str) -> Dict[str, str]:
"""Parse cookie string into dictionary"""
cookies = {}
for item in cookie_str.split('; '):
if '=' in item:
key, value = item.split('=', 1)
cookies[key] = value
return cookies
async def init_session(self) -> None:
"""Fetch lsd and fb_dtsg tokens from Meta AI page"""
print("[Meta Video] Initializing session tokens...")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
META_AI_BASE,
headers={
"Cookie": self.cookies_str,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
)
html = response.text
# Extract LSD token
lsd_match = re.search(r'"LSD",\[\],\{"token":"([^"]+)"', html)
if lsd_match:
self.lsd = lsd_match.group(1)
else:
# Fallback patterns
lsd_match = re.search(r'"lsd":"([^"]+)"', html) or \
re.search(r'name="lsd" value="([^"]+)"', html)
if lsd_match:
self.lsd = lsd_match.group(1)
# Extract FB DTSG token
dtsg_match = re.search(r'DTSGInitData",\[\],\{"token":"([^"]+)"', html)
if dtsg_match:
self.fb_dtsg = dtsg_match.group(1)
else:
dtsg_match = re.search(r'"DTSGInitialData".*?"token":"([^"]+)"', html)
if dtsg_match:
self.fb_dtsg = dtsg_match.group(1)
if not self.lsd or not self.fb_dtsg:
if 'login_form' in html or 'facebook.com/login' in html:
raise Exception("Meta AI: Cookies expired or invalid - please refresh")
raise Exception("Meta AI: Failed to extract session tokens")
print(f"[Meta Video] Got tokens - lsd: {self.lsd[:10]}..., dtsg: {self.fb_dtsg[:10]}...")
async def generate_video(
self,
prompt: str,
wait_before_poll: int = 10,
max_attempts: int = 30,
poll_interval: int = 5
) -> List[MetaVideoResult]:
"""
Generate video from text prompt.
Args:
prompt: Text prompt for video generation
wait_before_poll: Seconds to wait before polling
max_attempts: Maximum polling attempts
poll_interval: Seconds between polls
Returns:
List of MetaVideoResult with video URLs
"""
# Initialize session if needed
if not self.lsd or not self.fb_dtsg:
await self.init_session()
# Step 1: Create video generation request
print(f"[Meta Video] Generating video for: \"{prompt[:50]}...\"")
conversation_id = await self._create_video_request(prompt)
if not conversation_id:
raise Exception("Failed to create video generation request")
print(f"[Meta Video] Got conversation ID: {conversation_id}")
# Step 2: Wait before polling
print(f"[Meta Video] Waiting {wait_before_poll}s before polling...")
await asyncio.sleep(wait_before_poll)
# Step 3: Poll for video URLs
video_urls = await self._poll_for_videos(
conversation_id,
max_attempts=max_attempts,
poll_interval=poll_interval
)
if not video_urls:
raise Exception("No videos generated after polling")
return [
MetaVideoResult(url=url, prompt=prompt, conversation_id=conversation_id)
for url in video_urls
]
async def _create_video_request(self, prompt: str) -> Optional[str]:
"""Send video generation request to Meta AI"""
external_conversation_id = str(uuid.uuid4())
offline_threading_id = str(int(time.time() * 1000000000))[:19]
thread_session_id = str(uuid.uuid4())
bot_offline_threading_id = str(int(time.time() * 1000000000) + 1)[:19]
qpl_join_id = str(uuid.uuid4()).replace('-', '')
spin_t = str(int(time.time()))
# Build variables JSON
variables = json.dumps({
"message": {"sensitive_string_value": prompt},
"externalConversationId": external_conversation_id,
"offlineThreadingId": offline_threading_id,
"threadSessionId": thread_session_id,
"isNewConversation": True,
"suggestedPromptIndex": None,
"promptPrefix": None,
"entrypoint": "KADABRA__CHAT__UNIFIED_INPUT_BAR",
"attachments": [],
"attachmentsV2": [],
"activeMediaSets": [],
"activeCardVersions": [],
"activeArtifactVersion": None,
"userUploadEditModeInput": None,
"reelComposeInput": None,
"qplJoinId": qpl_join_id,
"sourceRemixPostId": None,
"gkPlannerOrReasoningEnabled": True,
"selectedModel": "BASIC_OPTION",
"conversationMode": None,
"selectedAgentType": "PLANNER",
"conversationStarterId": None,
"promptType": None,
"artifactRewriteOptions": None,
"imagineOperationRequest": None,
"imagineClientOptions": {"orientation": "VERTICAL"},
"spaceId": None,
"sparkSnapshotId": None,
"topicPageId": None,
"includeSpace": False,
"storybookId": None,
"messagePersistentInput": {
"attachment_size": None,
"attachment_type": None,
"bot_message_offline_threading_id": bot_offline_threading_id,
"conversation_mode": None,
"external_conversation_id": external_conversation_id,
"is_new_conversation": True,
"meta_ai_entry_point": "KADABRA__CHAT__UNIFIED_INPUT_BAR",
"offline_threading_id": offline_threading_id,
"prompt_id": None,
"prompt_session_id": thread_session_id
},
"alakazam_enabled": True,
"skipInFlightMessageWithParams": None,
"__relay_internal__pv__KadabraSocialSearchEnabledrelayprovider": False,
"__relay_internal__pv__KadabraZeitgeistEnabledrelayprovider": False,
"__relay_internal__pv__alakazam_enabledrelayprovider": True,
"__relay_internal__pv__AbraArtifactsEnabledrelayprovider": True,
"__relay_internal__pv__AbraPlannerEnabledrelayprovider": True,
"__relay_internal__pv__WebPixelRatiorelayprovider": 1,
"__relay_internal__pv__KadabraVideoDeliveryRequestrelayprovider": {
"dash_manifest_requests": [{}],
"progressive_url_requests": [{"quality": "HD"}, {"quality": "SD"}]
}
}, separators=(',', ':'))
# Build multipart form body
boundary = "----WebKitFormBoundaryu59CeaZS4ag939lz"
body = self._build_multipart_body(boundary, variables, spin_t)
headers = {
'accept': '*/*',
'accept-language': 'en-US,en;q=0.5',
'content-type': f'multipart/form-data; boundary={boundary}',
'origin': META_AI_BASE,
'referer': f'{META_AI_BASE}/',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'x-fb-lsd': self.lsd,
}
url = f"{GRAPHQL_URL}?fb_dtsg={self.fb_dtsg}&jazoest=25499&lsd={self.lsd}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
headers=headers,
cookies=self.cookies_dict,
content=body.encode('utf-8')
)
if response.status_code == 200:
return external_conversation_id
else:
print(f"[Meta Video] Request failed: {response.status_code}")
return None
except Exception as e:
print(f"[Meta Video] Request error: {e}")
return None
def _build_multipart_body(
self,
boundary: str,
variables: str,
spin_t: str
) -> str:
"""Build multipart form body for video request"""
parts = [
('av', '813590375178585'),
('__user', '0'),
('__a', '1'),
('__req', 'q'),
('__hs', '20413.HYP:kadabra_pkg.2.1...0'),
('dpr', '1'),
('__ccg', 'GOOD'),
('__rev', '1030219547'),
('__s', 'q59jx4:9bnqdw:3ats33'),
('__hsi', '7575127759957881428'),
('__dyn', '7xeUjG1mxu1syUqxemh0no6u5U4e2C1vzEdE98K360CEbo1nEhw2nVEtwMw6ywaq221FwpUO0n24oaEnxO0Bo7O2l0Fwqo31w9O1lwlE-U2zxe2GewbS361qw82dUlwhE-15wmo423-0j52oS0Io5d0bS1LBwNwKG0WE8oC1IwGw-wlUcE2-G2O7E5y1rwa211wo84y1iwfe1aw'),
('__csr', ''),
('__comet_req', '72'),
('fb_dtsg', self.fb_dtsg),
('jazoest', '25499'),
('lsd', self.lsd),
('__spin_r', '1030219547'),
('__spin_b', 'trunk'),
('__spin_t', spin_t),
('__jssesw', '1'),
('__crn', 'comet.kadabra.KadabraAssistantRoute'),
('fb_api_caller_class', 'RelayModern'),
('fb_api_req_friendly_name', 'useKadabraSendMessageMutation'),
('server_timestamps', 'true'),
('variables', variables),
('doc_id', '25290947477183545'),
]
body_lines = []
for name, value in parts:
body_lines.append(f'------{boundary[4:]}')
body_lines.append(f'Content-Disposition: form-data; name="{name}"')
body_lines.append('')
body_lines.append(value)
body_lines.append(f'------{boundary[4:]}--')
return '\r\n'.join(body_lines) + '\r\n'
async def _poll_for_videos(
self,
conversation_id: str,
max_attempts: int = 30,
poll_interval: int = 5
) -> List[str]:
"""Poll for video URLs from a conversation"""
print(f"[Meta Video] Polling for videos (max {max_attempts} attempts)...")
variables = {
"prompt_id": conversation_id,
"__relay_internal__pv__AbraIsLoggedOutrelayprovider": False,
"__relay_internal__pv__alakazam_enabledrelayprovider": True,
"__relay_internal__pv__AbraArtifactsEnabledrelayprovider": True,
"__relay_internal__pv__AbraPlannerEnabledrelayprovider": True,
"__relay_internal__pv__WebPixelRatiorelayprovider": 1,
"__relay_internal__pv__KadabraVideoDeliveryRequestrelayprovider": {
"dash_manifest_requests": [{}],
"progressive_url_requests": [{"quality": "HD"}, {"quality": "SD"}]
}
}
data = {
'av': '813590375178585',
'__user': '0',
'__a': '1',
'__req': 's',
'__hs': '20413.HYP:kadabra_pkg.2.1...0',
'dpr': '1',
'__ccg': 'GOOD',
'__rev': '1030219547',
'__comet_req': '72',
'fb_dtsg': self.fb_dtsg,
'jazoest': '25499',
'lsd': self.lsd,
'__spin_r': '1030219547',
'__spin_b': 'trunk',
'__spin_t': str(int(time.time())),
'__jssesw': '1',
'__crn': 'comet.kadabra.KadabraAssistantRoute',
'fb_api_caller_class': 'RelayModern',
'fb_api_req_friendly_name': 'KadabraPromptRootQuery',
'server_timestamps': 'true',
'variables': json.dumps(variables),
'doc_id': '25290569913909283',
}
headers = {
'accept': '*/*',
'accept-language': 'en-US,en;q=0.5',
'content-type': 'application/x-www-form-urlencoded',
'origin': META_AI_BASE,
'referer': f'{META_AI_BASE}/',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'x-fb-lsd': self.lsd,
'x-fb-friendly-name': 'KadabraPromptRootQuery'
}
for attempt in range(1, max_attempts + 1):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
GRAPHQL_URL,
headers=headers,
cookies=self.cookies_dict,
data=data
)
if response.status_code == 200:
video_urls = self._extract_video_urls(response.text)
if video_urls:
print(f"[Meta Video] Found {len(video_urls)} video(s) on attempt {attempt}")
return video_urls
else:
print(f"[Meta Video] Attempt {attempt}/{max_attempts} - no videos yet")
except Exception as e:
print(f"[Meta Video] Poll error: {e}")
await asyncio.sleep(poll_interval)
return []
def _extract_video_urls(self, response_text: str) -> List[str]:
"""Extract video URLs from Meta AI response"""
video_urls = set()
try:
data = json.loads(response_text)
def search_for_urls(obj):
if isinstance(obj, dict):
for key, value in obj.items():
if key in ['video_url', 'progressive_url', 'generated_video_uri', 'uri', 'url']:
if isinstance(value, str) and 'fbcdn' in value and '.mp4' in value:
video_urls.add(value)
search_for_urls(value)
elif isinstance(obj, list):
for item in obj:
search_for_urls(item)
elif isinstance(obj, str):
if 'fbcdn' in obj and ('.mp4' in obj or 'video' in obj.lower()):
urls = re.findall(r'https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*', obj)
video_urls.update(urls)
search_for_urls(data)
except json.JSONDecodeError:
# Fallback regex extraction
urls = re.findall(r'https?://[^\s"\'<>]+fbcdn[^\s"\'<>]+\.mp4[^\s"\'<>]*', response_text)
video_urls.update(urls)
return list(video_urls)

View file

@ -1,151 +0,0 @@
"""
Prompts Service for Python/FastAPI
Port of lib/prompts-service.ts
Handles:
- Read/write prompts.json
- Sync prompts from crawlers (placeholder - crawlers complex to port)
"""
import json
import os
import asyncio
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
# Path to prompts data file (relative to project root)
DATA_DIR = Path(__file__).parent.parent.parent / "data"
DATA_FILE = DATA_DIR / "prompts.json"
class Prompt:
def __init__(self, data: Dict[str, Any]):
self.id = data.get("id", 0)
self.title = data.get("title", "")
self.description = data.get("description", "")
self.prompt = data.get("prompt", "")
self.category = data.get("category", "")
self.source = data.get("source", "")
self.source_url = data.get("source_url", "")
self.images = data.get("images", [])
self.use_count = data.get("useCount", 0)
self.last_used_at = data.get("lastUsedAt")
self.created_at = data.get("createdAt")
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"prompt": self.prompt,
"category": self.category,
"source": self.source,
"source_url": self.source_url,
"images": self.images,
"useCount": self.use_count,
"lastUsedAt": self.last_used_at,
"createdAt": self.created_at
}
class PromptCache:
def __init__(self, data: Dict[str, Any]):
self.prompts = [Prompt(p) for p in data.get("prompts", [])]
self.last_updated = data.get("last_updated")
self.last_sync = data.get("lastSync")
self.categories = data.get("categories", {})
self.total_count = data.get("total_count", 0)
self.sources = data.get("sources", [])
def to_dict(self) -> Dict[str, Any]:
return {
"prompts": [p.to_dict() for p in self.prompts],
"last_updated": self.last_updated,
"lastSync": self.last_sync,
"categories": self.categories,
"total_count": self.total_count,
"sources": self.sources
}
async def get_prompts() -> PromptCache:
"""Read prompts from JSON file"""
try:
if DATA_FILE.exists():
content = DATA_FILE.read_text(encoding='utf-8')
data = json.loads(content)
return PromptCache(data)
except Exception as e:
print(f"[PromptsService] Error reading prompts: {e}")
return PromptCache({
"prompts": [],
"last_updated": None,
"categories": {},
"total_count": 0,
"sources": []
})
async def save_prompts(cache: PromptCache) -> None:
"""Save prompts to JSON file"""
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
content = json.dumps(cache.to_dict(), indent=2, ensure_ascii=False)
DATA_FILE.write_text(content, encoding='utf-8')
except Exception as e:
print(f"[PromptsService] Error saving prompts: {e}")
raise
async def sync_prompts() -> Dict[str, Any]:
"""
Sync prompts from sources.
Note: The crawler implementation is complex and would require porting
the JavaScript crawlers. For now, this just refreshes the timestamp.
"""
print("[PromptsService] Starting sync...")
cache = await get_prompts()
now = int(datetime.now().timestamp() * 1000)
# Update sync timestamp
cache.last_sync = now
cache.last_updated = datetime.now().isoformat()
await save_prompts(cache)
return {
"success": True,
"count": len(cache.prompts),
"added": 0
}
async def track_prompt_use(prompt_id: int) -> Optional[Prompt]:
"""Track usage of a prompt"""
cache = await get_prompts()
for prompt in cache.prompts:
if prompt.id == prompt_id:
prompt.use_count += 1
prompt.last_used_at = int(datetime.now().timestamp() * 1000)
await save_prompts(cache)
return prompt
return None
async def upload_prompt_image(prompt_id: int, image_base64: str) -> Optional[Prompt]:
"""Upload an image for a prompt"""
cache = await get_prompts()
for prompt in cache.prompts:
if prompt.id == prompt_id:
if prompt.images is None:
prompt.images = []
prompt.images.append(f"data:image/png;base64,{image_base64}")
await save_prompts(cache)
return prompt
return None

View file

@ -1,410 +0,0 @@
"""
Whisk Client for Python/FastAPI
Port of lib/whisk-client.ts
Handles:
- Cookie parsing (JSON array or string format)
- Access token retrieval from Whisk API
- Image generation with aspect ratio support
- Reference image upload
- Video generation with polling
"""
import httpx
import json
import uuid
import base64
import asyncio
from typing import Optional, Dict, List, Any
# Whisk API endpoints
AUTH_URL = "https://aisandbox-pa.googleapis.com/v1:signInWithIdp"
GENERATE_URL = "https://aisandbox-pa.googleapis.com/v1:runImagine"
RECIPE_URL = "https://aisandbox-pa.googleapis.com/v1:runRecipe"
UPLOAD_URL = "https://aisandbox-pa.googleapis.com/v1:uploadMedia"
VIDEO_URL = "https://aisandbox-pa.googleapis.com/v1:runVideoFxSingleClips"
VIDEO_STATUS_URL = "https://aisandbox-pa.googleapis.com/v1:runVideoFxSingleClipsStatusCheck"
# Aspect ratio mapping
ASPECT_RATIOS = {
"1:1": "IMAGE_ASPECT_RATIO_SQUARE",
"9:16": "IMAGE_ASPECT_RATIO_PORTRAIT",
"16:9": "IMAGE_ASPECT_RATIO_LANDSCAPE",
"4:3": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
"3:4": "IMAGE_ASPECT_RATIO_PORTRAIT",
"Auto": "IMAGE_ASPECT_RATIO_SQUARE"
}
MEDIA_CATEGORIES = {
"subject": "MEDIA_CATEGORY_SUBJECT",
"scene": "MEDIA_CATEGORY_SCENE",
"style": "MEDIA_CATEGORY_STYLE"
}
class GeneratedImage:
def __init__(self, data: str, index: int, prompt: str, aspect_ratio: str):
self.data = data
self.index = index
self.prompt = prompt
self.aspect_ratio = aspect_ratio
def to_dict(self) -> Dict[str, Any]:
return {
"data": self.data,
"index": self.index,
"prompt": self.prompt,
"aspectRatio": self.aspect_ratio
}
class WhiskVideoResult:
def __init__(self, id: str, url: Optional[str], status: str):
self.id = id
self.url = url
self.status = status
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"url": self.url,
"status": self.status
}
class WhiskClient:
def __init__(self, cookie_input: str):
self.cookies = self._parse_cookies(cookie_input)
self.access_token: Optional[str] = None
self.token_expires: int = 0
self.cookie_string = ""
if not self.cookies:
raise ValueError("No valid cookies provided")
# Build cookie string for requests
self.cookie_string = "; ".join(
f"{name}={value}" for name, value in self.cookies.items()
)
def _parse_cookies(self, input_str: str) -> Dict[str, str]:
"""Parse cookies from string or JSON format"""
if not input_str or not input_str.strip():
return {}
trimmed = input_str.strip()
cookies: Dict[str, str] = {}
# Handle JSON array format (e.g., from Cookie-Editor)
if trimmed.startswith('[') or trimmed.startswith('{'):
try:
parsed = json.loads(trimmed)
if isinstance(parsed, list):
for c in parsed:
if isinstance(c, dict) and 'name' in c and 'value' in c:
cookies[c['name']] = c['value']
return cookies
elif isinstance(parsed, dict) and 'name' in parsed and 'value' in parsed:
return {parsed['name']: parsed['value']}
except json.JSONDecodeError:
pass
# Handle string format (key=value; key2=value2)
for pair in trimmed.split(';'):
pair = pair.strip()
if '=' in pair:
key, _, value = pair.partition('=')
cookies[key.strip()] = value.strip()
return cookies
async def get_access_token(self) -> str:
"""Get or refresh access token from Whisk API"""
import time
# Return cached token if still valid
if self.access_token and self.token_expires > int(time.time() * 1000):
return self.access_token
async with httpx.AsyncClient() as client:
response = await client.post(
AUTH_URL,
headers={
"Content-Type": "application/json",
"Cookie": self.cookie_string
},
json={}
)
if response.status_code != 200:
raise Exception(f"Auth failed: {response.status_code} - {response.text[:200]}")
data = response.json()
self.access_token = data.get("authToken")
expires_in = int(data.get("expiresIn", 3600))
self.token_expires = int(time.time() * 1000) + (expires_in * 1000) - 60000
if not self.access_token:
raise Exception("No auth token in response")
return self.access_token
async def upload_reference_image(
self,
file_base64: str,
mime_type: str,
category: str
) -> Optional[str]:
"""Upload a reference image and return media ID"""
token = await self.get_access_token()
data_uri = f"data:{mime_type};base64,{file_base64}"
media_category = MEDIA_CATEGORIES.get(category.lower(), MEDIA_CATEGORIES["subject"])
payload = {
"mediaData": data_uri,
"imageOptions": {
"imageCategory": media_category
}
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
UPLOAD_URL,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json=payload
)
if response.status_code != 200:
print(f"[WhiskClient] Upload failed: {response.status_code}")
raise Exception(f"Upload failed: {response.text[:200]}")
data = response.json()
media_id = data.get("generationId") or data.get("imageMediaId")
if not media_id:
print(f"[WhiskClient] No media ID in response: {data}")
return None
print(f"[WhiskClient] Upload successful, mediaId: {media_id}")
return media_id
async def generate(
self,
prompt: str,
aspect_ratio: str = "1:1",
refs: Optional[Dict[str, Any]] = None,
precise_mode: bool = False
) -> List[GeneratedImage]:
"""Generate images using Whisk API"""
token = await self.get_access_token()
refs = refs or {}
# Build media inputs
media_inputs = []
def add_refs(category: str, ids):
"""Helper to add refs (handles both single string and array)"""
if not ids:
return
id_list = [ids] if isinstance(ids, str) else ids
cat_enum = MEDIA_CATEGORIES.get(category.lower())
for ref_id in id_list:
if ref_id:
media_inputs.append({
"mediaId": ref_id,
"mediaCategory": cat_enum
})
add_refs("subject", refs.get("subject"))
add_refs("scene", refs.get("scene"))
add_refs("style", refs.get("style"))
# Build payload
aspect_enum = ASPECT_RATIOS.get(aspect_ratio, ASPECT_RATIOS["1:1"])
# Determine endpoint based on refs
has_refs = len(media_inputs) > 0
endpoint = RECIPE_URL if has_refs else GENERATE_URL
if has_refs:
# Recipe format (with refs)
recipe_inputs = []
def add_recipe_refs(category: str, ids):
if not ids:
return
id_list = [ids] if isinstance(ids, str) else ids
cat_enum = MEDIA_CATEGORIES.get(category.lower())
for ref_id in id_list:
if ref_id:
recipe_inputs.append({
"inputType": cat_enum,
"mediaId": ref_id
})
add_recipe_refs("subject", refs.get("subject"))
add_recipe_refs("scene", refs.get("scene"))
add_recipe_refs("style", refs.get("style"))
payload = {
"recipeInputs": recipe_inputs,
"generationConfig": {
"aspectRatio": aspect_enum,
"numberOfImages": 4,
"personalizationConfig": {}
},
"textPromptInput": {
"text": prompt
}
}
else:
# Direct imagine format (no refs)
payload = {
"imagineConfig": {
"aspectRatio": aspect_enum,
"imaginePrompt": prompt,
"numberOfImages": 4,
"imageSafetyMode": "BLOCK_SOME"
}
}
print(f"[WhiskClient] Generating with prompt: \"{prompt[:50]}...\"")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
endpoint,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json=payload
)
if response.status_code != 200:
error_text = response.text[:500]
if "401" in error_text or "403" in error_text:
raise Exception("Whisk auth failed - cookies may be expired")
raise Exception(f"Generation failed: {response.status_code} - {error_text}")
data = response.json()
# Extract images
images: List[GeneratedImage] = []
image_list = data.get("generatedImages", [])
for i, img in enumerate(image_list):
image_data = img.get("encodedImage", "")
if image_data:
images.append(GeneratedImage(
data=image_data,
index=i,
prompt=prompt,
aspect_ratio=aspect_ratio
))
print(f"[WhiskClient] Generated {len(images)} images")
return images
async def generate_video(
self,
image_generation_id: str,
prompt: str,
image_base64: Optional[str] = None,
aspect_ratio: str = "16:9"
) -> WhiskVideoResult:
"""Generate a video from an image using Whisk Animate (Veo)"""
token = await self.get_access_token()
# If we have base64 but no generation ID, upload first
actual_gen_id = image_generation_id
if not actual_gen_id and image_base64:
actual_gen_id = await self.upload_reference_image(
image_base64, "image/png", "subject"
)
if not actual_gen_id:
raise Exception("No image generation ID available for video")
payload = {
"generationId": actual_gen_id,
"videoFxConfig": {
"aspectRatio": aspect_ratio.replace(":", "_"),
"prompt": prompt,
"duration": "5s"
}
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
VIDEO_URL,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json=payload
)
if response.status_code != 200:
raise Exception(f"Video init failed: {response.text[:200]}")
data = response.json()
video_gen_id = data.get("videoGenId")
if not video_gen_id:
raise Exception("No video generation ID in response")
print(f"[WhiskClient] Video generation started: {video_gen_id}")
# Poll for completion
return await self.poll_video_status(video_gen_id, token)
async def poll_video_status(
self,
video_gen_id: str,
token: str
) -> WhiskVideoResult:
"""Poll for video generation status until complete or failed"""
max_attempts = 60
poll_interval = 3
async with httpx.AsyncClient(timeout=30.0) as client:
for attempt in range(max_attempts):
print(f"[WhiskClient] Polling video status {attempt + 1}/{max_attempts}...")
response = await client.post(
VIDEO_STATUS_URL,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"Cookie": self.cookie_string
},
json={"videoGenId": video_gen_id}
)
if response.status_code != 200:
await asyncio.sleep(poll_interval)
continue
data = response.json()
status = data.get("status", "")
video_url = data.get("videoUri")
if status == "COMPLETE" and video_url:
print(f"[WhiskClient] Video complete: {video_url[:50]}...")
return WhiskVideoResult(
id=video_gen_id,
url=video_url,
status="complete"
)
elif status in ["FAILED", "ERROR"]:
raise Exception(f"Video generation failed: {status}")
await asyncio.sleep(poll_interval)
raise Exception("Video generation timed out")

View file

@ -1,100 +0,0 @@
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>
);
}

View file

@ -26,43 +26,37 @@ export function CookieExpiredDialog() {
}; };
return ( return (
<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="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="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"> <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">
{/* Top Glow/Gradient */} {/* Decorative header background */}
<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="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="relative p-8 flex flex-col items-center text-center"> <div className="relative p-6 px-8 flex flex-col items-center text-center">
{/* Close Button */}
<button <button
onClick={() => setShowCookieExpired(false)} onClick={() => setShowCookieExpired(false)}
className="absolute top-6 right-6 p-2 text-white/20 hover:text-white transition-colors" className="absolute top-4 right-4 p-2 text-white/40 hover:text-white rounded-full hover:bg-white/5 transition-colors"
> >
<X className="h-5 w-5" /> <X className="h-4 w-4" />
</button> </button>
{/* Cookie Icon Container */} <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">
<div className="relative mt-4 mb-8"> <Cookie className="h-8 w-8 text-amber-500" />
{/* 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-2xl font-black text-white mb-4 tracking-tight">Cookies Expired</h2> <h2 className="text-xl font-bold text-white mb-2">Cookies Expired</h2>
<p className="text-[#A1A1AA] text-[15px] mb-8 leading-relaxed max-w-[280px]"> <p className="text-muted-foreground text-sm mb-6 leading-relaxed">
Your <span className="text-white font-bold">{providerName}</span> session has timed out. Your <span className="text-white font-medium">{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-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]" 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"
> >
<Settings className="h-5 w-5" /> <Settings className="h-4 w-4" />
Update Settings Update Settings
</button> </button>
@ -70,9 +64,9 @@ export function CookieExpiredDialog() {
href={providerUrl} href={providerUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
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]" 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"
> >
<ExternalLink className="h-5 w-5" /> <ExternalLink className="h-4 w-4" />
Open {providerName} Open {providerName}
</a> </a>
</div> </div>

View file

@ -1,96 +0,0 @@
"use client";
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({ errorInfo });
}
handleReload = () => {
window.location.reload();
};
handleClearStorage = () => {
try {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
} catch (e) {
console.error("Failed to clear storage:", e);
window.location.reload();
}
};
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-[#0a0a0b] text-white flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[#18181B] border border-white/10 rounded-2xl p-6 text-center">
<div className="h-16 w-16 mx-auto mb-4 rounded-full bg-red-500/10 flex items-center justify-center">
<svg className="h-8 w-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 className="text-xl font-bold mb-2">Something went wrong</h2>
<p className="text-muted-foreground text-sm mb-4">
An error occurred while rendering the application. This might be due to corrupted data or a temporary issue.
</p>
<div className="space-y-3">
<button
onClick={this.handleReload}
className="w-full py-3 px-4 bg-primary text-primary-foreground font-semibold rounded-xl hover:bg-primary/90 transition-all"
>
Reload Page
</button>
<button
onClick={this.handleClearStorage}
className="w-full py-3 px-4 bg-white/5 hover:bg-white/10 text-white font-medium rounded-xl transition-all border border-white/5"
>
Clear Cache & Reload
</button>
</div>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="mt-4 text-left">
<summary className="text-xs text-muted-foreground cursor-pointer">Error Details</summary>
<pre className="mt-2 p-2 bg-black/50 rounded text-xs overflow-auto max-h-40 text-red-400">
{this.state.error.toString()}
{this.state.errorInfo?.componentStack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View file

@ -8,9 +8,6 @@ import { Download, Maximize2, Sparkles, Trash2, X, ChevronLeft, ChevronRight, Co
import { VideoPromptModal } from './VideoPromptModal'; import { VideoPromptModal } from './VideoPromptModal';
import { EditPromptModal } from './EditPromptModal'; import { EditPromptModal } from './EditPromptModal';
// FastAPI backend URL - /api in production (nginx proxy), localhost in dev
const API_BASE = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? '/api' : 'http://localhost:8000');
// Helper function to get proper image src (handles URLs vs base64) // Helper function to get proper image src (handles URLs vs base64)
const getImageSrc = (data: string): string => { const getImageSrc = (data: string): string => {
if (!data) return ''; if (!data) return '';
@ -40,15 +37,6 @@ 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]) {
@ -109,12 +97,11 @@ export function Gallery() {
const m = safeParse(settings.metaCookies); const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies); const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) { if (Array.isArray(m) || Array.isArray(f)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any; mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
} }
} catch (e) { console.error("Cookie merge failed", e); } } catch (e) { console.error("Cookie merge failed", e); }
const res = await fetch(`${API_BASE}/meta/video`, { const res = await fetch('/api/meta/video', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -141,7 +128,7 @@ export function Gallery() {
} else { } else {
throw new Error(data.error || 'No videos generated'); throw new Error(data.error || 'No videos generated');
} }
} catch (error: any) { // eslint-disable-line @typescript-eslint/no-explicit-any } catch (error: any) {
console.error("[Gallery] Meta video error:", error); console.error("[Gallery] Meta video error:", error);
let errorMessage = error.message || 'Video generation failed'; let errorMessage = error.message || 'Video generation failed';
if (errorMessage.includes('401') || errorMessage.includes('cookies') || errorMessage.includes('expired')) { if (errorMessage.includes('401') || errorMessage.includes('cookies') || errorMessage.includes('expired')) {
@ -171,7 +158,7 @@ export function Gallery() {
setIsGeneratingWhiskVideo(true); setIsGeneratingWhiskVideo(true);
try { try {
const res = await fetch(`${API_BASE}/video/generate`, { const res = await fetch('/api/video/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -238,12 +225,11 @@ export function Gallery() {
const m = safeParse(settings.metaCookies); const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies); const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) { if (Array.isArray(m) || Array.isArray(f)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any; mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
} }
} catch (e) { console.error("Cookie merge failed", e); } } catch (e) { console.error("Cookie merge failed", e); }
const res = await fetch(`${API_BASE}/meta/generate`, { const res = await fetch('/api/meta/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -258,7 +244,6 @@ export function Gallery() {
if (data.success && data.images?.length > 0) { if (data.success && data.images?.length > 0) {
// Add new images to gallery // Add new images to gallery
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newImages = data.images.map((img: any) => ({ const newImages = data.images.map((img: any) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
data: img.data, // Base64 data: img.data, // Base64
@ -278,7 +263,7 @@ export function Gallery() {
} else { } else {
throw new Error('No images generated'); throw new Error('No images generated');
} }
} catch (e: any) { // eslint-disable-line @typescript-eslint/no-explicit-any } catch (e: any) {
console.error("Meta Remix failed", e); console.error("Meta Remix failed", e);
alert("Remix failed: " + e.message); alert("Remix failed: " + e.message);
} }
@ -292,7 +277,7 @@ export function Gallery() {
} }
// First upload the current image as a reference // First upload the current image as a reference
const uploadRes = await fetch(`${API_BASE}/references/upload`, { const uploadRes = await fetch('/api/references/upload', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -315,7 +300,7 @@ export function Gallery() {
if (options.keepStyle) refs.style = [uploadData.id]; if (options.keepStyle) refs.style = [uploadData.id];
// Generate new image with references // Generate new image with references
const res = await fetch(`${API_BASE}/generate`, { const res = await fetch('/api/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -404,28 +389,25 @@ 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-8 px-2 relative z-10"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h2 className="text-xl font-extrabold text-foreground tracking-tight">{gallery.length} Creations</h2> <h2 className="text-xl font-semibold">{gallery.length} Generated Images</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 px-1"> <div className="flex items-center gap-2 mb-4">
<Film className="h-5 w-5 text-blue-500" /> <Film className="h-5 w-5 text-blue-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{videos.length} Generated Video{videos.length > 1 ? 's' : ''}</h3> <h3 className="text-lg font-semibold">{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) => (
@ -434,7 +416,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-2xl overflow-hidden bg-black border border-gray-200 dark:border-gray-800 shadow-sm" className="group relative aspect-video rounded-xl overflow-hidden bg-black border border-white/10 shadow-lg"
> >
<video <video
src={vid.url} src={vid.url}
@ -445,13 +427,13 @@ export function Gallery() {
/> />
<button <button
onClick={() => removeVideo(vid.id)} onClick={() => removeVideo(vid.id)}
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" 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"
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-4 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-3 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<p className="text-white text-xs line-clamp-1 font-medium">{vid.prompt}</p> <p className="text-white text-xs line-clamp-1">{vid.prompt}</p>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -460,12 +442,15 @@ export function Gallery() {
)} )}
{/* Gallery Grid */} {/* Gallery Grid */}
<div className="columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4"> <div className="columns-1 sm: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-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 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 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>
))} ))}
</> </>
@ -475,44 +460,45 @@ 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, y: 20 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
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" className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm"
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 group-hover:scale-110 transition-transform duration-700" className="w-full h-auto object-cover transition-transform group-hover:scale-105 cursor-pointer"
onClick={() => setSelectedIndex(i)}
loading="lazy" loading="lazy"
/> />
{/* Overlay Gradient */} {/* Provider Tag */}
<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" /> {img.provider && (
<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>
)}
{/* Provider Badge */} {/* Delete button - Top right */}
<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-1.5 bg-black/50 hover:bg-destructive/80 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
title="Delete"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
{/* Caption - Glass Style */} {/* Hover Overlay - Simplified: just show prompt */}
<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"> <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none">
<p className="text-white text-[10px] font-bold line-clamp-2 leading-tight tracking-tight"> <p className="text-white text-xs line-clamp-2">{img.prompt}</p>
{img.prompt}
</p>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -526,280 +512,220 @@ 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-[110] flex items-center justify-center bg-background/95 dark:bg-black/95 backdrop-blur-3xl" className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 backdrop-blur-md p-4 md:p-6"
onClick={() => setSelectedIndex(null)} onClick={() => setSelectedIndex(null)}
> >
{/* Top Controls Bar */} {/* Close Button */}
<div className="absolute top-0 inset-x-0 h-20 flex items-center justify-between px-6 z-[120] pointer-events-none"> <button
<div className="pointer-events-auto"> className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
<button onClick={() => setSelectedIndex(null)}
onClick={() => setShowControls(!showControls)} >
className={cn( <X className="h-5 w-5" />
"p-3 rounded-full transition-all border shadow-xl backdrop-blur-md active:scale-95", </button>
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>
{/* Navigation Buttons */}
{selectedIndex > 0 && (
<button <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" 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={() => setSelectedIndex(null)} onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
> >
<X className="h-5 w-5" /> <ChevronLeft className="h-6 w-6 md:h-8 md:w-8" />
</button> </button>
</div> )}
{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 h-full flex flex-col md:flex-row gap-0 overflow-hidden" className="relative w-full max-w-6xl max-h-[90vh] flex flex-col md:flex-row gap-4 md:gap-6 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 Container (Full size) */} {/* Left: Image */}
<div className="flex-1 flex items-center justify-center min-h-0 relative group/arrows p-4 md:p-12"> <div className="flex-1 flex items-center justify-center min-h-0">
<motion.img <img
layout
src={getImageSrc(selectedImage.data)} src={getImageSrc(selectedImage.data)}
alt={selectedImage.prompt} alt={selectedImage.prompt}
className={cn( className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
"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 (Retractable) */} {/* Right: Controls Panel */}
<AnimatePresence> <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">
{showControls && ( {/* Provider Badge */}
<motion.div {selectedImage.provider && (
initial={isMobile ? { y: "100%", opacity: 0 } : { x: "100%", opacity: 0 }} <div className={cn(
animate={{ x: 0, y: 0, opacity: 1 }} "self-start px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider",
exit={isMobile ? { y: "100%", opacity: 0 } : { x: "100%", opacity: 0 }} selectedImage.provider === 'meta' ? "bg-blue-500/20 text-blue-300 border border-blue-500/30" :
transition={{ type: "spring", damping: 30, stiffness: 300, mass: 0.8 }} "bg-amber-500/20 text-amber-300 border border-amber-500/30"
drag={isMobile ? "y" : false} )}>
dragConstraints={{ top: 0, bottom: 0 }} {selectedImage.provider}
dragElastic={{ top: 0, bottom: 0.5 }} </div>
onDragEnd={(_, info) => { )}
if (isMobile && info.offset.y > 100) {
setShowControls(false); {/* Prompt Section (Editable) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Prompt</h3>
{editPromptValue !== selectedImage.prompt && (
<span className="text-[10px] text-amber-400 font-medium animate-pulse">Modified</span>
)}
</div>
<textarea
value={editPromptValue}
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"
placeholder="Enter prompt..."
/>
<div className="flex gap-2">
{(!selectedImage.provider || selectedImage.provider === 'whisk' || selectedImage.provider === 'meta') && (
<button
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"
>
<Wand2 className="h-3 w-3" />
<span>Remix</span>
</button>
)}
<button
onClick={() => {
navigator.clipboard.writeText(editPromptValue);
alert("Prompt copied!");
}}
className={cn(
"px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors",
(!selectedImage.provider || selectedImage.provider === 'whisk') ? "" : "flex-1"
)}
title="Copy Prompt"
>
<Copy className="h-4 w-4 mx-auto" />
</button>
</div>
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Video Generation Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider flex items-center gap-2">
<Film className="h-3 w-3" />
Animate
</h3>
</div>
<textarea
value={videoPromptValue}
onChange={(e) => setVideoPromptValue(e.target.value)}
placeholder="Describe movement (e.g. natural movement, zoom in)..."
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"
/>
{(() => {
const isGenerating = isGeneratingMetaVideo || isGeneratingWhiskVideo;
const isWhisk = !selectedImage.provider || selectedImage.provider === 'whisk';
const isMeta = selectedImage.provider === 'meta';
const is16by9 = selectedImage.aspectRatio === '16:9';
// Only Whisk with 16:9 can generate video - Meta video API not available
const canGenerate = isWhisk && is16by9;
return (
<button
onClick={() => handleGenerateVideo(videoPromptValue, selectedImage)}
disabled={isGenerating || !canGenerate}
className={cn(
"relative z-10 w-full py-2 rounded-lg text-xs font-medium text-white transition-all flex items-center justify-center gap-2",
isGenerating
? "bg-gray-600 cursor-wait"
: !canGenerate
? "bg-gray-600/50 cursor-not-allowed opacity-60"
: "bg-purple-600 hover:bg-purple-500"
)}
>
{isGenerating ? (
<>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Generating Video...</span>
</>
) : isMeta ? (
<>
<Film className="h-3.5 w-3.5 opacity-50" />
<span>Video coming soon</span>
</>
) : !canGenerate ? (
<>
<Film className="h-3.5 w-3.5 opacity-50" />
<span>Video requires 16:9 ratio</span>
</>
) : (
<>
<Film className="h-3.5 w-3.5" />
<span>Generate Video</span>
</>
)}
</button>
);
})()}
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Other Actions */}
<div className="space-y-2">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Other Actions</h3>
<div className="grid grid-cols-2 gap-2">
<a
href={getImageSrc(selectedImage.data)}
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"
>
<Download className="h-3.5 w-3.5" />
<span>Download</span>
</a>
<button
onClick={() => {
setPrompt(selectedImage.prompt);
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"
>
<Sparkles className="h-3.5 w-3.5" />
<span>Use Prompt</span>
</button>
</div>
<button
onClick={() => {
if (selectedImage.id) {
removeFromGallery(selectedImage.id);
setSelectedIndex(null);
} }
}} }}
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" 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"
> >
{/* Drag Handle (Mobile) */} <Trash2 className="h-3.5 w-3.5" />
<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" /> <span>Delete Image</span>
</button>
</div>
<div className="flex-1 flex flex-col gap-6 p-6 md:p-8 overflow-y-auto custom-scrollbar pb-32 md:pb-8"> {/* Image Info */}
{/* Provider Badge */} <div className="mt-auto pt-3 border-t border-white/10 text-xs text-white/40 space-y-1">
{selectedImage.provider && ( {selectedImage.aspectRatio && (
<div className={cn( <p>Aspect Ratio: {selectedImage.aspectRatio}</p>
"self-start px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider", )}
selectedImage.provider === 'meta' ? "bg-blue-500/20 text-blue-300 border border-blue-500/30" : <p>Image {selectedIndex + 1} of {gallery.length}</p>
"bg-amber-500/20 text-amber-300 border border-amber-500/30" </div>
)}> </div>
{selectedImage.provider}
</div>
)}
{/* Prompt Section (Editable) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/70">Original Prompt</h3>
{editPromptValue !== selectedImage.prompt && (
<span className="text-[10px] text-amber-400 font-medium animate-pulse">Modified</span>
)}
</div>
<textarea
value={editPromptValue}
onChange={(e) => setEditPromptValue(e.target.value)}
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..."
/>
<div className="flex gap-2">
{(!selectedImage.provider || selectedImage.provider === 'whisk' || selectedImage.provider === 'meta') && (
<button
onClick={() => openEditModal({ ...selectedImage, prompt: editPromptValue })}
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" />
<span>Remix</span>
</button>
)}
<button
onClick={() => {
navigator.clipboard.writeText(editPromptValue);
alert("Prompt copied!");
}}
className={cn(
"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"
)}
title="Copy Prompt"
>
<Copy className="h-4 w-4 mx-auto" />
</button>
</div>
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Video Generation Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/70 flex items-center gap-2">
<Film className="h-3 w-3" />
Animate
</h3>
</div>
<textarea
value={videoPromptValue}
onChange={(e) => setVideoPromptValue(e.target.value)}
placeholder="Describe movement..."
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 isWhisk = !selectedImage.provider || selectedImage.provider === 'whisk';
const isMeta = selectedImage.provider === 'meta';
const is16by9 = selectedImage.aspectRatio === '16:9';
// Only Whisk with 16:9 can generate video - Meta video API not available
const canGenerate = isWhisk && is16by9;
return (
<button
onClick={() => handleGenerateVideo(videoPromptValue, selectedImage)}
disabled={isGenerating || !canGenerate}
className={cn(
"relative z-10 w-full py-2 rounded-lg text-xs font-medium text-white transition-all flex items-center justify-center gap-2",
isGenerating
? "bg-gray-600 cursor-wait"
: !canGenerate
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50 border border-border/50"
: "bg-blue-600 hover:bg-blue-500 shadow-lg shadow-blue-500/20"
)}
>
{isGenerating ? (
<>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Generating Video...</span>
</>
) : isMeta ? (
<>
<Film className="h-3.5 w-3.5 opacity-50" />
<span>Video coming soon</span>
</>
) : !canGenerate ? (
<>
<Film className="h-3.5 w-3.5 opacity-50" />
<span>Video requires 16:9 ratio</span>
</>
) : (
<>
<Film className="h-3.5 w-3.5" />
<span>Generate Video</span>
</>
)}
</button>
);
})()}
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Other Actions */}
<div className="space-y-2">
<h3 className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/70">Library Actions</h3>
<div className="grid grid-cols-2 gap-2">
<a
href={getImageSrc(selectedImage.data)}
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
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" />
<span>Download</span>
</a>
<button
onClick={() => {
setPrompt(selectedImage.prompt);
setSelectedIndex(null);
}}
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" />
<span>Use Prompt</span>
</button>
</div>
<button
onClick={() => {
if (selectedImage.id) {
removeFromGallery(selectedImage.id);
setSelectedIndex(null);
}
}}
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" />
<span>Delete Image</span>
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}

View file

@ -1,123 +0,0 @@
"use client";
import React, { useState } from 'react';
import { Smartphone, Monitor, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
export function MobileCookieInstructions() {
const [open, setOpen] = useState(false);
const [platform, setPlatform] = useState<'desktop' | 'android' | 'ios'>('desktop');
return (
<div className="border border-border/50 rounded-xl bg-card overflow-hidden shadow-soft">
<button
onClick={() => setOpen(!open)}
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">
<Smartphone className="h-5 w-5 text-primary" />
<span className="font-bold text-sm text-foreground">How to get cookies on Mobile?</span>
</div>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</button>
{open && (
<div className="p-6 border-t border-border/10 bg-muted/20 space-y-6">
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
<button
onClick={() => setPlatform('desktop')}
className={cn(
"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-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.5 w-3.5" />
Desktop Sync (Best)
</button>
<button
onClick={() => setPlatform('android')}
className={cn(
"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-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.5 w-3.5" />
Android
</button>
<button
onClick={() => setPlatform('ios')}
className={cn(
"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-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.5 w-3.5" />
iPhone (iOS)
</button>
</div>
<div className="space-y-4 max-w-2xl">
{platform === 'desktop' && (
<div className="space-y-4 animate-in fade-in slide-in-from-left-4 duration-500">
<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="space-y-3">
{[
{ step: 1, text: <>Install <strong className="text-foreground">Cookie-Editor</strong> extension on your PC browser (Chrome/Edge).</> },
{ 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.</> },
{ step: 3, text: <>Open extension &rarr; Click <strong className="text-foreground">Export</strong> &rarr; <strong className="text-foreground">Export as JSON</strong>.</> },
{ 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>
</div>
)}
{platform === 'android' && (
<div className="space-y-4 animate-in fade-in slide-in-from-left-4 duration-500">
<p className="text-sm font-medium text-muted-foreground leading-relaxed">Android allows extensions via specific browsers.</p>
<ol className="space-y-3">
{[
{ step: 1, text: <>Install <strong className="text-foreground">Kiwi Browser</strong> or <strong className="text-foreground">Firefox Nightly</strong> from Play Store.</> },
{ step: 2, text: <>Open Kiwi/Firefox and install <strong className="text-foreground">Cookie-Editor</strong> from Chrome Web Store.</> },
{ step: 3, text: <>Login to the service (Whisk/Facebook).</> },
{ step: 4, text: <>Tap menu &rarr; Cookie-Editor &rarr; 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>
</div>
)}
{platform === 'ios' && (
<div className="space-y-4 animate-in fade-in slide-in-from-left-4 duration-500">
<p className="text-sm font-medium text-muted-foreground leading-relaxed">iPhone is restrictive. Syncing from Desktop is recommended.</p>
<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 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>
<ol className="space-y-3">
{[
{ 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>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -1,25 +1,13 @@
"use client"; "use client";
import React, { useEffect } from 'react'; import React from 'react';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { Sparkles, LayoutGrid, Clock, Settings, User, Sun, Moon } from 'lucide-react'; import { Sparkles, LayoutGrid, Clock, Settings, User } 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 },
@ -29,11 +17,11 @@ export function Navbar() {
return ( return (
<> <>
<div className="fixed top-0 left-0 right-0 z-50 glass-panel border-b border-border shadow-soft md:hidden"> <div className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border">
{/* Visual Highlight Line */} {/* Yellow Accent Line */}
<div className="h-0.5 w-full bg-gradient-to-r from-transparent via-primary/50 to-transparent" /> <div className="h-1 w-full bg-primary" />
<div className="flex items-center justify-between px-6 h-16 max-w-7xl mx-auto"> <div className="flex items-center justify-between px-4 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">
@ -43,7 +31,7 @@ export function Navbar() {
</div> </div>
{/* Center Navigation (Desktop) */} {/* Center Navigation (Desktop) */}
<div className="hidden md:flex items-center gap-1 bg-muted/20 p-1 rounded-full border border-border/50"> <div className="hidden md:flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/50">
{navItems.map((item) => ( {navItems.map((item) => (
<button <button
key={item.id} key={item.id}
@ -55,7 +43,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-muted/50" : "text-muted-foreground hover:text-foreground hover:bg-secondary"
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
@ -66,12 +54,6 @@ 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(
@ -84,16 +66,62 @@ 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-4 py-1.5 bg-card/50 hover:bg-secondary/20 border border-border/50 rounded-full transition-all group active:scale-95"> <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">
<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"> <div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-bold">
KV KV
</div> </div>
<span className="text-xs font-semibold hidden sm:block text-foreground/80 group-hover:text-foreground">Khoa Vo</span> <span className="text-sm font-medium hidden sm:block">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>
</> </>
); );
} }

View file

@ -5,9 +5,6 @@ import { useStore, ReferenceCategory } from "@/lib/store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Brain, Settings, Settings2 } from "lucide-react"; import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Brain, Settings, Settings2 } from "lucide-react";
// FastAPI backend URL - /api in production (nginx proxy), localhost in dev
const API_BASE = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? '/api' : 'http://localhost:8000');
const IMAGE_COUNTS = [1, 2, 4]; const IMAGE_COUNTS = [1, 2, 4];
export function PromptHero() { export function PromptHero() {
@ -129,7 +126,7 @@ export function PromptHero() {
const subjectRef = references.subject?.[0]; const subjectRef = references.subject?.[0];
const imageUrl = subjectRef ? subjectRef.thumbnail : undefined; // Use full data URI from thumbnail property const imageUrl = subjectRef ? subjectRef.thumbnail : undefined; // Use full data URI from thumbnail property
res = await fetch(`${API_BASE}/meta/generate`, { res = await fetch('/api/meta/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -148,7 +145,7 @@ export function PromptHero() {
style: references.style?.map(r => r.id) || [], style: references.style?.map(r => r.id) || [],
}; };
res = await fetch(`${API_BASE}/generate`, { res = await fetch('/api/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -320,7 +317,7 @@ export function PromptHero() {
// If Whisk, upload to backend to get ID // If Whisk, upload to backend to get ID
if (!settings.provider || settings.provider === 'whisk') { if (!settings.provider || settings.provider === 'whisk') {
try { try {
const res = await fetch(`${API_BASE}/references/upload`, { const res = await fetch('/api/references/upload', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -437,25 +434,28 @@ export function PromptHero() {
); );
return ( return (
<div className="w-full max-w-lg md:max-w-3xl mx-auto mb-8 transition-all"> <div className="w-full max-w-3xl mx-auto my-4 md:my-6 px-4">
{/* Error/Warning Notification Toast */} {/* Error/Warning Notification Toast */}
{errorNotification && ( {errorNotification && (
<div className={cn( <div className={cn(
"mb-4 p-3 rounded-xl border flex items-start gap-3 animate-in fade-in slide-in-from-top-4 duration-300", "mb-4 p-3 rounded-lg border flex items-start gap-3 animate-in slide-in-from-top-4 duration-300",
errorNotification.type === 'warning' errorNotification.type === 'warning'
? "bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400" ? "bg-amber-500/10 border-amber-500/30 text-amber-200"
: "bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400" : "bg-red-500/10 border-red-500/30 text-red-200"
)}> )}>
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" /> <AlertTriangle className={cn(
"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-semibold"> <p className="text-sm font-medium">
{errorNotification.type === 'warning' ? 'Content Moderation' : 'Generation Error'} {errorNotification.type === 'warning' ? '⚠️ Content Moderation' : 'Generation Error'}
</p> </p>
<p className="text-xs mt-1 opacity-90 leading-relaxed">{errorNotification.message}</p> <p className="text-xs mt-1 opacity-80">{errorNotification.message}</p>
</div> </div>
<button <button
onClick={() => setErrorNotification(null)} onClick={() => setErrorNotification(null)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors" className="p-1 hover:bg-white/10 rounded-full transition-colors"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@ -463,163 +463,300 @@ export function PromptHero() {
)} )}
<div className={cn( <div className={cn(
"bg-card rounded-3xl p-6 md:p-8 shadow-premium border border-border/50 relative overflow-hidden transition-all duration-500", "relative flex flex-col gap-3 rounded-2xl bg-[#1A1A1E]/95 bg-gradient-to-b from-white/[0.02] to-transparent p-4 shadow-xl border border-white/5 backdrop-blur-sm transition-all",
isGenerating && "ring-2 ring-primary/20 border-primary/50 shadow-lg shadow-primary/10" isGenerating && "ring-1 ring-purple-500/30"
)}> )}>
{/* 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 */} {/* Header / Title + Provider Toggle */}
<div className="flex items-center justify-between mb-8 relative z-10"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-3"> <div className="flex items-center gap-3">
<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"> <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">
{settings.provider === 'meta' ? <Brain className="h-5 w-5" /> : <Sparkles className="h-5 w-5" />} {settings.provider === 'meta' ? (
<Brain className="h-4 w-4 text-blue-400" />
) : (
<Sparkles className="h-4 w-4 text-amber-300" />
)}
</div> </div>
<div> <div>
<h2 className="font-extrabold text-xl text-foreground tracking-tight">Create</h2> <h2 className="text-base font-bold text-white tracking-tight flex items-center gap-2">
<span className="text-[10px] uppercase font-bold tracking-widest text-muted-foreground"> Create
Powered by <span className="text-secondary">{settings.provider === 'meta' ? 'Meta AI' : 'Google Whisk'}</span> <span className="text-[10px] font-medium text-white/40 border-l border-white/10 pl-2">
</span> by <span className={cn(
settings.provider === 'meta' ? "text-blue-400" :
"text-amber-300"
)}>
{settings.provider === 'meta' ? 'Meta AI' :
'Whisk'}
</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: settings.provider === 'meta' ? 'whisk' : 'meta' })} onClick={() => setSettings({ provider: 'whisk' })}
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" className={cn(
title="Switch Provider" "flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-medium transition-all",
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"
> >
<Settings2 className="h-3.5 w-3.5 text-primary" /> <Sparkles className="h-3 w-3" />
<span>Switch</span> <span className="hidden sm:inline">Whisk</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 mb-6 group z-10"> <div className="relative group">
<div className="absolute -inset-0.5 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-xl blur opacity-0 group-hover:opacity-100 transition duration-500" />
<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}
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" placeholder="Describe your imagination..."
placeholder="What's on your mind? Describe your vision..." 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"
/> />
</div> </div>
{/* Reference Upload Grid */} {/* Controls Area */}
<div className="grid grid-cols-3 gap-4 mb-6 relative z-10"> <div className="flex flex-col md:flex-row items-center justify-between gap-3 pt-1">
{((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 ( {/* Left Controls: References */}
<button {/* For Meta AI: Only Subject is enabled (for video generation), Scene/Style disabled */}
key={cat} <div className="flex flex-wrap gap-2">
onClick={() => toggleReference(cat)} {((settings.provider === 'meta'
onDragOver={handleDragOver} ? ['subject']
onDrop={(e) => handleDrop(e, cat)} : ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
className={cn( const refs = references[cat] || [];
"flex flex-col items-center justify-center gap-2 py-4 rounded-2xl border transition-all relative overflow-hidden group/btn shadow-soft", const hasRefs = refs.length > 0;
hasRefs const isUploading = uploadingRefs[cat];
? "bg-primary/5 border-primary/30"
: "bg-muted/50 hover:bg-muted border-border/50" return (
)} <div key={cat} className="relative group">
> <button
{isUploading ? ( onClick={() => toggleReference(cat)}
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" /> onDragOver={handleDragOver}
) : hasRefs ? ( onDrop={(e) => handleDrop(e, cat)}
<div className="relative pt-1"> title={settings.provider === 'meta' && cat === 'subject'
<div className="flex -space-x-2.5 justify-center"> ? "Upload image to animate into video"
{refs.slice(0, 3).map((ref, idx) => ( : undefined}
<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 }} /> className={cn(
))} "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-medium transition-all border relative overflow-hidden",
</div> hasRefs
<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> ? "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>
{/* Clear all 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); }}
title={`Clear all ${cat} references`}
>
<X className="h-2 w-2" />
</button>
)}
</div> </div>
) : ( );
<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={cn(
"text-[10px] uppercase font-black tracking-widest transition-colors",
hasRefs ? "text-primary" : "text-muted-foreground"
)}>
{cat}
</span>
</button>
);
})}
</div>
{/* Settings & Generate Row */}
<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">
<button
onClick={settings.provider === 'meta' ? undefined : cycleImageCount}
className={cn(
"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.5 w-3.5 text-primary" />
<span className="text-xs font-bold text-foreground">{settings.provider === 'meta' ? 4 : settings.imageCount}</span>
</button>
<button
onClick={nextAspectRatio}
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl hover:bg-card transition-all whitespace-nowrap"
title="Aspect Ratio"
>
<Maximize2 className="h-3.5 w-3.5 text-secondary" />
<span className="text-xs font-bold text-foreground">{settings.aspectRatio}</span>
</button>
<button
onClick={() => setSettings({ preciseMode: !settings.preciseMode })}
className={cn(
"px-2.5 py-2 rounded-xl transition-all font-black text-[10px] tracking-tight uppercase whitespace-nowrap",
settings.preciseMode
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:bg-card"
)}
title="Precise Mode"
>
Precise
</button>
</div> </div>
<button {/* Hidden file inputs for upload */}
onClick={handleGenerate} <input
disabled={isGenerating || !prompt.trim()} type="file"
className={cn( ref={fileInputRefs.subject}
"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", accept="image/*"
isGenerating && "animate-pulse" multiple
)} className="hidden"
> onChange={(e) => handleFileInputChange(e, 'subject')}
{isGenerating ? ( />
<> <input
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> type="file"
<span>Generating...</span> ref={fileInputRefs.scene}
</> accept="image/*"
) : ( multiple
<> className="hidden"
<Sparkles className="h-4 w-4 group-hover/gen:rotate-12 transition-transform" /> onChange={(e) => handleFileInputChange(e, 'scene')}
<span>Dream Big</span> />
</> <input
)} type="file"
</button> ref={fileInputRefs.style}
</div> accept="image/*"
</div> multiple
className="hidden"
onChange={(e) => handleFileInputChange(e, 'style')}
/>
{/* Hidden File Inputs */}
<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')} /> {/* Right Controls: Settings & Generate */}
<input type="file" ref={fileInputRefs.style} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'style')} /> <div className="flex flex-wrap items-center gap-2 w-full md:w-auto justify-end">
{/* Settings Group */}
<div className="flex items-center gap-0.5 bg-[#0E0E10] p-1 rounded-lg border border-white/10">
{/* Image Count */}
<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"
)}
title={settings.provider === 'meta' ? "Meta AI always generates 4 images" : "Number of images"}
>
<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" />
{/* Aspect Ratio */}
<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"
title="Aspect Ratio"
>
<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" />
{/* Precise Mode */}
<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"
)}
title="Precise Mode"
>
{/* <span>🍌</span> */}
<span>Precise</span>
</button>
</div>
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className={cn(
"relative overflow-hidden px-4 py-1.5 rounded-lg font-bold text-sm text-white shadow-lg transition-all active:scale-95 group border border-white/10",
"bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 hover:shadow-indigo-500/25"
)}
>
<div className="relative z-10 flex items-center gap-1.5">
{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-3 w-3 group-hover:rotate-12 transition-transform" />
<span>Generate</span>
</>
)}
</div>
</button>
</div>
</div>
{/* 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 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-10 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> </div>
); );
} }

View file

@ -7,9 +7,6 @@ import { cn } from '@/lib/utils';
import { Prompt, PromptCache } from '@/lib/types'; import { Prompt, PromptCache } from '@/lib/types';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
// FastAPI backend URL - /api in production (nginx proxy), localhost in dev
const API_BASE = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? '/api' : 'http://localhost:8000');
export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => void }) { export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => void }) {
const { setPrompt, settings } = useStore(); const { setPrompt, settings } = useStore();
const [prompts, setPrompts] = useState<Prompt[]>([]); const [prompts, setPrompts] = useState<Prompt[]>([]);
@ -20,23 +17,16 @@ 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_BASE}/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);
} }
@ -44,14 +34,12 @@ 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_BASE}/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);
} }
@ -73,7 +61,7 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
try { try {
console.log(`Requesting preview for: ${prompt.title}`); console.log(`Requesting preview for: ${prompt.title}`);
const res = await fetch(`${API_BASE}/prompts/generate`, { const res = await fetch('/api/prompts/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -121,7 +109,7 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
// Track usage // Track usage
try { try {
await fetch(`${API_BASE}/prompts/use`, { await fetch('/api/prompts/use', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id }) body: JSON.stringify({ id: p.id })
@ -176,137 +164,146 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
return filteredPrompts; return filteredPrompts;
}; };
// Pagination State
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(24);
// Reset pagination when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, selectedCategory, selectedSource, sortMode]);
const finalPrompts = displayPrompts(); const finalPrompts = displayPrompts();
// Pagination Logic
const totalPages = Math.ceil(finalPrompts.length / itemsPerPage);
const paginatedPrompts = finalPrompts.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const uniqueCategories = ['All', ...Array.from(new Set(prompts.map(p => p.category)))].filter(Boolean); const uniqueCategories = ['All', ...Array.from(new Set(prompts.map(p => p.category)))].filter(Boolean);
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-10 pb-32"> <div className="max-w-6xl mx-auto p-4 md:p-8 space-y-8 pb-32">
<div className="flex flex-col items-center text-center gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-col items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-4 bg-primary/10 rounded-2xl text-primary shadow-sm"> <div className="p-3 bg-primary/10 rounded-xl text-primary">
<Sparkles className="h-8 w-8" /> <Sparkles className="h-6 w-6" />
</div> </div>
<div> <div>
<h2 className="text-3xl font-black tracking-tight">Prompt Library</h2> <h2 className="text-2xl font-bold">Prompt Library</h2>
<p className="text-muted-foreground text-sm font-medium">Curated inspiration from the community.</p> <p className="text-muted-foreground">Curated inspiration from the community.</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<button
onClick={generateMissingPreviews}
disabled={generating}
className={cn(
"p-2 hover:bg-secondary rounded-full transition-colors",
generating && "animate-pulse text-yellow-500"
)}
title="Auto-Generate Missing Previews"
>
<ImageIcon className="h-5 w-5" />
</button>
<div className="flex flex-col items-center gap-4 w-full max-w-2xl"> <button
<div className="relative flex-1 w-full group"> onClick={syncPrompts}
<input disabled={loading}
type="text" className="p-2 hover:bg-secondary rounded-full transition-colors"
placeholder="Search prompts..." title="Sync from GitHub"
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} <RefreshCw className={cn("h-5 w-5", loading && "animate-spin")} />
onChange={(e) => setSearchTerm(e.target.value)} </button>
/> <input
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors"> type="text"
<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> placeholder="Search prompts..."
</div> className="px-4 py-2 rounded-lg bg-card border focus:border-primary focus:outline-none w-full md:w-64"
value={searchTerm}
{/* Compact Action Buttons inside search bar */} onChange={(e) => setSearchTerm(e.target.value)}
<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
onClick={generateMissingPreviews}
disabled={generating}
className={cn(
"p-1.5 hover:bg-card rounded-lg transition-all active:scale-90",
generating && "animate-pulse text-primary bg-card shadow-sm"
)}
title="Renew/Generate Previews"
>
<ImageIcon className="h-4 w-4" />
</button>
<div className="w-px h-4 bg-border/50 mx-0.5" />
<button
onClick={syncPrompts}
disabled={loading}
className="p-1.5 hover:bg-card rounded-lg transition-all active:scale-90"
title="Sync Library"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
</div>
</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/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"> <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">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
<span className="font-bold text-xs uppercase tracking-wider">Generating library previews...</span> <span className="font-medium">Generating preview images for library prompts... This may take a while.</span>
</div> </div>
)} )}
{/* Smart Tabs */} {/* Smart Tabs */}
<div className="flex justify-center"> <div className="flex items-center gap-1 bg-secondary/30 p-1 rounded-xl w-fit">
<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 */} {/* Sub-Categories */}
{sortMode === 'all' && ( {sortMode === 'all' && (
<div className="flex flex-wrap gap-2 justify-center max-w-4xl mx-auto"> <div className="flex flex-wrap gap-2 py-4 overflow-x-auto scrollbar-hide">
{uniqueCategories.map(cat => ( {(() => {
<button const priority = ['NAM', 'NỮ', 'SINH NHẬT', 'HALLOWEEN', 'NOEL', 'NEW YEAR', 'TRẺ EM', 'COUPLE', 'CHA - MẸ', 'MẸ BẦU', 'ĐẶC BIỆT'];
key={cat}
onClick={() => setSelectedCategory(cat)} // Sort uniqueCategories (which only contains categories that exist in data)
className={cn( const sortedCategories = uniqueCategories.sort((a, b) => {
"px-5 py-2 rounded-2xl text-xs font-bold transition-all border active:scale-95", if (a === 'All') return -1;
selectedCategory === cat if (b === 'All') return 1;
? "bg-secondary text-secondary-foreground border-transparent shadow-md"
: "bg-card hover:bg-muted text-muted-foreground border-border/50" const idxA = priority.indexOf(a);
)} const idxB = priority.indexOf(b);
>
{cat} if (idxA !== -1 && idxB !== -1) return idxA - idxB;
</button> if (idxA !== -1) return -1;
))} if (idxB !== -1) return 1;
return a.localeCompare(b);
});
return sortedCategories.map(cat => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={cn(
"px-4 py-2 text-sm font-bold uppercase tracking-wider transition-all duration-200 rounded-md whitespace-nowrap",
selectedCategory === cat
? "bg-[#8B1E1E] text-white border border-white/80 shadow-[0_0_12px_rgba(139,30,30,0.6)]" // Active: Deep Red + Glow
: "text-gray-400 hover:text-yellow-400 border border-transparent hover:bg-white/5" // Inactive: Yellow Hover
)}
>
{cat}
</button>
));
})()}
</div> </div>
)} )}
{/* Source Filter */} {/* Source Filter */}
<div className="flex flex-wrap gap-3 items-center justify-center pt-2"> <div className="flex flex-wrap gap-2 items-center">
<span className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60 mr-1">Sources:</span> <span className="text-sm font-medium text-muted-foreground mr-2">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-4 py-1.5 rounded-xl text-[10px] font-black tracking-widest uppercase transition-all border active:scale-95", "px-3 py-1 rounded-full text-xs font-medium transition-colors border",
selectedSource === source selectedSource === source
? "bg-primary/10 text-primary border-primary/20 shadow-sm" ? "bg-primary text-primary-foreground border-primary"
: "bg-muted/30 hover:bg-muted text-muted-foreground/70 border-border/30" : "bg-card hover:bg-secondary text-muted-foreground border-secondary"
)} )}
> >
{source} {source}
@ -314,69 +311,137 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
))} ))}
</div> </div>
{/* Top Pagination Controls */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 py-2">
<span className="text-sm font-medium text-muted-foreground mr-2">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Prev
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Next
</button>
</div>
)}
{loading && !prompts.length ? ( {loading && !prompts.length ? (
<div className="flex justify-center py-20"> <div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <>
<AnimatePresence mode="popLayout"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{finalPrompts.map((p) => ( <AnimatePresence mode="popLayout">
<motion.div {paginatedPrompts.map((p) => (
key={p.id} <motion.div
layout key={p.id}
initial={{ opacity: 0, scale: 0.9 }} layout
animate={{ opacity: 1, scale: 1 }} initial={{ opacity: 0, scale: 0.9 }}
exit={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }}
className="group relative flex flex-col bg-card border rounded-xl overflow-hidden hover:border-primary/50 transition-all hover:shadow-lg" exit={{ opacity: 0, scale: 0.9 }}
> className="group relative flex flex-col bg-card border rounded-xl overflow-hidden hover:border-primary/50 transition-all hover:shadow-lg"
{p.images && p.images.length > 0 ? ( >
<div className="aspect-video relative overflow-hidden bg-secondary/50"> {p.images && p.images.length > 0 ? (
<img <div className="aspect-video relative overflow-hidden bg-secondary/50">
src={p.images[0]} <img
alt={p.title} src={p.images[0]}
className="object-cover w-full h-full transition-transform group-hover:scale-105" alt={p.title}
loading="lazy" className="object-cover w-full h-full transition-transform group-hover:scale-105"
/> loading="lazy"
</div> />
) : ( </div>
<div className="aspect-video bg-gradient-to-br from-secondary to-background p-4 flex items-center justify-center text-muted-foreground/20"> ) : (
<Sparkles className="h-12 w-12" /> <div className="aspect-video bg-gradient-to-br from-secondary to-background p-4 flex items-center justify-center text-muted-foreground/20">
</div> <Sparkles className="h-12 w-12" />
)} </div>
)}
<div className="p-4 flex flex-col flex-1 gap-3"> <div className="p-4 flex flex-col flex-1 gap-3">
<div className="flex justify-between items-start gap-2"> <div className="flex justify-between items-start gap-2">
<h3 className="font-semibold line-clamp-1" title={p.title}>{p.title}</h3> <h3 className="font-semibold line-clamp-1" title={p.title}>{p.title}</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground whitespace-nowrap"> <span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground whitespace-nowrap">
{p.source} {p.source}
</span> </span>
</div> </div>
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 font-mono bg-secondary/30 p-2 rounded"> <p className="text-sm text-muted-foreground line-clamp-3 flex-1 font-mono bg-secondary/30 p-2 rounded">
{p.prompt} {p.prompt}
</p> </p>
<div className="flex items-center justify-between pt-2 border-t mt-auto"> <div className="flex items-center justify-between pt-2 border-t mt-auto">
<button <button
onClick={() => handleSelect(p)} onClick={() => handleSelect(p)}
className="text-xs font-medium text-primary hover:underline flex items-center gap-1" className="text-xs font-medium text-primary hover:underline flex items-center gap-1"
> >
Use Prompt Use Prompt
</button> </button>
<button <button
onClick={() => navigator.clipboard.writeText(p.prompt)} onClick={() => navigator.clipboard.writeText(p.prompt)}
className="p-1.5 text-muted-foreground hover:text-primary transition-colors" className="p-1.5 text-muted-foreground hover:text-primary transition-colors"
title="Copy to clipboard" title="Copy to clipboard"
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</button> </button>
</div>
</div> </div>
</div> </motion.div>
</motion.div> ))}
))} </AnimatePresence>
</AnimatePresence> </div>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-8 border-t mt-8">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
{[24, 48, 96].map(size => (
<button
key={size}
onClick={() => { setItemsPerPage(size); setCurrentPage(1); }}
className={cn(
"px-2 py-1 rounded text-xs font-medium transition-colors",
itemsPerPage === size
? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground hover:bg-secondary/80"
)}
>
{size}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Previous
</button>
<span className="text-sm font-medium text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 rounded-lg bg-secondary text-sm font-medium disabled:opacity-50 hover:bg-secondary/80 transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)} )}
{!loading && finalPrompts.length === 0 && ( {!loading && finalPrompts.length === 0 && (

View file

@ -2,11 +2,8 @@
import React from 'react'; import React from 'react';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { Save, Sparkles, Brain, Settings2, Moon, Sun, Monitor, Check } from 'lucide-react'; import { Save, Sparkles, Brain, Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTheme } from '@/components/theme-provider';
import { MobileCookieInstructions } from './MobileCookieInstructions';
type Provider = 'whisk' | 'meta'; type Provider = 'whisk' | 'meta';
@ -17,27 +14,6 @@ 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');
@ -47,8 +23,6 @@ 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({
@ -64,283 +38,143 @@ export function Settings() {
}; };
return ( return (
<div className="space-y-8 pb-32"> <div className="max-w-2xl mx-auto space-y-8 p-4 md:p-8">
{/* Header Section */} <div>
<div className="px-2"> <h2 className="text-2xl font-bold mb-2">Settings</h2>
<h2 className="text-2xl font-black text-foreground tracking-tight">Settings</h2> <p className="text-muted-foreground">Configure your AI image generation provider.</p>
<p className="text-sm font-medium text-muted-foreground mt-1">Configure your AI preferences and API credentials.</p>
</div> </div>
{/* General Preferences Card */} {/* Provider Selection */}
<div className="bg-card rounded-3xl p-6 md:p-8 shadow-premium border border-border/50 space-y-8 relative overflow-hidden"> <div className="space-y-3">
<div className="absolute top-0 right-0 -mr-12 -mt-12 w-48 h-48 bg-primary/5 rounded-full blur-2xl" /> <label className="text-sm font-medium">Image Generation Provider</label>
<div className="grid grid-cols-3 gap-3">
<section className="space-y-6 relative z-10"> {providers.map((p) => (
<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) => (
<button
key={p.id}
onClick={() => setProvider(p.id)}
className={cn(
"flex items-center gap-4 p-4 rounded-2xl border-2 transition-all active:scale-[0.98] text-left",
provider === p.id
? "border-primary bg-primary/5 ring-4 ring-primary/10"
: "border-border/50 hover:border-primary/30 bg-background/50"
)}
>
<div className={cn(
"p-3 rounded-xl transition-colors",
provider === p.id ? "bg-primary text-white" : "bg-muted text-muted-foreground"
)}>
<p.icon className="h-6 w-6" />
</div>
<div>
<p className={cn("font-bold text-sm", provider === p.id ? "text-primary" : "text-foreground")}>{p.name}</p>
<p className="text-[10px] uppercase font-black tracking-widest text-muted-foreground mt-0.5">{p.description}</p>
</div>
</button>
))}
</div>
</section>
</div>
{/* Provider Credentials Card */}
<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 <button
onClick={handleSave} key={p.id}
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" onClick={() => setProvider(p.id)}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all",
provider === p.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 bg-card"
)}
> >
<Save className="h-4 w-4" /> <p.icon className={cn(
{saved ? "Saved Configuration" : "Save Configuration"} "h-6 w-6",
provider === p.id ? "text-primary" : "text-muted-foreground"
)} />
<span className={cn(
"font-medium text-sm",
provider === p.id ? "text-primary" : ""
)}>{p.name}</span>
<span className="text-xs text-muted-foreground">{p.description}</span>
</button> </button>
))}
</div>
</div>
{/* Provider-specific settings */}
<div className="space-y-4 p-4 rounded-xl bg-card border">
{provider === 'whisk' && (
<div className="space-y-2">
<label className="text-sm font-medium">Google Whisk Cookies</label>
<textarea
value={whiskCookies}
onChange={(e) => setWhiskCookies(e.target.value)}
placeholder="Paste your 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"
/>
<p className="text-xs text-muted-foreground">
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>.
</p>
</div> </div>
)}
<div className="grid grid-cols-1 gap-12"> {provider === 'meta' && (
{/* Whisk Settings */} <div className="space-y-4">
{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">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Authentication Cookies</label>
<textarea
value={whiskCookies}
onChange={(e) => setWhiskCookies(e.target.value)}
placeholder="Paste your Whisk cookies here..."
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"
/>
<MobileCookieInstructions provider="whisk" />
</div>
</div>
)}
{/* Meta AI Settings */} {/* Advanced Settings (Hidden by default) */}
{provider === 'meta' && ( <details className="group mb-4">
<div className="space-y-4 animate-in fade-in slide-in-from-right-4 duration-300"> <summary className="flex items-center gap-2 cursor-pointer text-xs text-white/40 hover:text-white/60 mb-2 select-none">
<div className="flex items-center justify-between"> <Settings2 className="h-3 w-3" />
<div className="flex items-center gap-2"> <span>Advanced Configuration</span>
<span className="h-2 w-2 rounded-full bg-blue-500" /> </summary>
<h4 className="text-xs font-black uppercase tracking-widest text-foreground">Meta AI Configuration</h4>
<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={() => { onClick={() => setUseMetaFreeWrapper(!useMetaFreeWrapper)}
const isValid = metaCookies.length > 50 && facebookCookies.length > 50; 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"}`}
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"
)}
> >
{metaVerified ? <Check className="h-3 w-3" /> : null} <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 ? "Verified" : "Verify Duo"}
</button> </button>
</div> </div>
</div> </div>
{/* Advanced Configuration (Meta Specific) */} {useMetaFreeWrapper && (
<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 && (
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Wrapper Endpoint</label>
<input
type="text"
value={metaFreeWrapperUrl}
onChange={(e) => setMetaFreeWrapperUrl(e.target.value)}
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"
/>
</div>
)}
</div>
</details>
{/* Meta Cookies Fields */}
<div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between px-1"> <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">Meta.ai Cookies</label> <input
<button type="text"
onClick={async () => { value={metaFreeWrapperUrl}
try { onChange={(e) => setMetaFreeWrapperUrl(e.target.value)}
const text = await navigator.clipboard.readText(); placeholder="http://localhost:8000"
setMetaCookies(text); 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"
} catch (err) {
console.error('Clipboard error', err);
alert('Clipboard permission denied.');
}
}}
className="text-[10px] font-bold text-primary hover:underline"
>
Paste
</button>
</div>
<textarea
value={metaCookies}
onChange={(e) => setMetaCookies(e.target.value)}
placeholder="Paste your Meta cookies..."
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"
/> />
</div> </div>
)}
<div className="space-y-2">
<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
value={facebookCookies}
onChange={(e) => setFacebookCookies(e.target.value)}
placeholder="Paste your Facebook cookies (REQUIRED)..."
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"
/>
</div>
<MobileCookieInstructions provider="meta" />
</div>
</div> </div>
)} </details>
<div className="pt-2 border-t border-white/5">
<p className="text-sm font-medium mb-3 text-amber-400">Authentication Required</p>
{/* Meta AI Cookies */}
<div className="space-y-2 mb-4">
<label className="text-sm font-medium">Meta.ai Cookies</label>
<textarea
value={metaCookies}
onChange={(e) => setMetaCookies(e.target.value)}
placeholder="Paste cookies from meta.ai..."
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"
/>
<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>
{/* Facebook Cookies */}
<div className="space-y-2">
<label className="text-sm font-medium">Facebook.com Cookies <span className="text-red-500">*</span></label>
<textarea
value={facebookCookies}
onChange={(e) => setFacebookCookies(e.target.value)}
placeholder="Paste cookies from facebook.com (REQUIRED for authentication)..."
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"
/>
<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>
</div> </div>
</section> )}
</div>
<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>
); );

View file

@ -5,9 +5,6 @@ import { useStore, ReferenceCategory } from '@/lib/store';
import { Clock, Upload, Trash2, CheckCircle, X, Film, Check } from 'lucide-react'; import { Clock, Upload, Trash2, CheckCircle, X, Film, Check } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// FastAPI backend URL - /api in production (nginx proxy), localhost in dev
const API_BASE = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? '/api' : 'http://localhost:8000');
export function UploadHistory() { export function UploadHistory() {
const { const {
history, setHistory, history, setHistory,
@ -30,7 +27,6 @@ export function UploadHistory() {
}; };
// Check if an item is currently selected as a reference // Check if an item is currently selected as a reference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSelected = (item: any) => { const isSelected = (item: any) => {
if (!selectionMode) return false; if (!selectionMode) return false;
const categoryRefs = references[selectionMode as ReferenceCategory] || []; const categoryRefs = references[selectionMode as ReferenceCategory] || [];
@ -39,7 +35,6 @@ export function UploadHistory() {
}; };
// Toggle selection - add or remove from references // Toggle selection - add or remove from references
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleToggleSelect = (item: any) => { const handleToggleSelect = (item: any) => {
if (!selectionMode) return; if (!selectionMode) return;
@ -118,7 +113,7 @@ export function UploadHistory() {
// Optimistic UI update could happen here // Optimistic UI update could happen here
const res = await fetch(`${API_BASE}/references/upload`, { const res = await fetch('/api/references/upload', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -207,65 +202,63 @@ export function UploadHistory() {
)} )}
{!selectionMode && ( {!selectionMode && (
<div className="flex flex-col items-center text-center gap-6 mb-12"> <div className="flex items-center justify-between mb-8">
<div className="flex flex-col items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-4 bg-secondary rounded-2xl text-primary shadow-sm"> <div className="p-3 bg-secondary rounded-xl text-primary">
<Clock className="h-8 w-8" /> <Clock className="h-6 w-6" />
</div> </div>
<div> <div>
<h2 className="text-3xl font-black tracking-tight">Uploads</h2> <h2 className="text-2xl font-bold">Uploads</h2>
<p className="text-muted-foreground text-sm font-medium">Your reference collection.</p> <p className="text-muted-foreground">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-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" className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-4 w-4" />
<span>Clear All History</span> <span>Clear All</span>
</button> </button>
)} )}
</div> </div>
)} )}
{/* Filter Tabs */} {/* Filter Tabs */}
<div className="flex justify-center mb-10 overflow-hidden"> <div className="flex items-center gap-2 mb-6 bg-secondary/30 p-1 rounded-xl w-fit">
<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-16 bg-card/30 rounded-[2.5rem] border border-dashed border-border/50 max-w-2xl mx-auto"> <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="p-6 bg-secondary/30 rounded-full mb-6"> <div className="p-4 bg-secondary/50 rounded-full mb-4">
<Film className="h-10 w-10 opacity-40 text-primary" /> <Film className="h-8 w-8 opacity-50" />
</div> </div>
<h3 className="text-xl font-black text-foreground mb-2">No videos yet</h3> <h3 className="text-lg font-medium mb-1">No videos yet</h3>
<p className="text-sm text-center font-medium opacity-60"> <p className="text-sm text-center max-w-xs">
Generate videos from your gallery images using the primary generator. Generate videos from your gallery images.
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{videos.map((vid) => ( {videos.map((vid) => (
<div key={vid.id} className="group relative aspect-video rounded-2xl overflow-hidden bg-black border border-border/50 shadow-lg"> <div key={vid.id} className="group relative aspect-video rounded-xl overflow-hidden bg-black border shadow-sm">
<video <video
src={vid.url} src={vid.url}
poster={`data:image/png;base64,${vid.thumbnail}`} poster={`data:image/png;base64,${vid.thumbnail}`}
@ -273,16 +266,16 @@ export function UploadHistory() {
controls controls
preload="metadata" preload="metadata"
/> />
<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"> <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">
<button <button
onClick={() => removeVideo(vid.id)} onClick={() => removeVideo(vid.id)}
className="p-2 bg-black/60 hover:bg-destructive text-white rounded-xl backdrop-blur-md transition-all active:scale-90" className="p-1.5 bg-black/50 hover:bg-destructive text-white rounded-full transition-colors"
> >
<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/95 via-black/40 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-all pointer-events-none"> <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">
<p className="text-white text-[10px] font-medium line-clamp-2 uppercase tracking-tight">{vid.prompt}</p> <p className="text-white text-xs line-clamp-1">{vid.prompt}</p>
</div> </div>
</div> </div>
))} ))}
@ -291,13 +284,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-16 bg-card/30 rounded-[2.5rem] border border-dashed border-border/50 max-w-2xl mx-auto"> <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="p-6 bg-secondary/30 rounded-full mb-6"> <div className="p-4 bg-secondary/50 rounded-full mb-4">
<Upload className="h-10 w-10 opacity-40 text-primary" /> <Clock className="h-8 w-8 opacity-50" />
</div> </div>
<h3 className="text-xl font-black text-foreground mb-2">No uploads yet</h3> <h3 className="text-lg font-medium mb-1">No uploads yet</h3>
<p className="text-sm text-center font-medium opacity-60"> <p className="text-sm text-center max-w-xs">
Drag and drop images anywhere or use the plus buttons in the creator. Drag and drop images here to upload.
</p> </p>
</div> </div>
) : ( ) : (

View file

@ -1,74 +0,0 @@
"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;
};

18002
data/habu_prompts.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,13 @@ services:
container_name: kv-pix container_name: kv-pix
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" # nginx serves both frontend and API - "8558:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
volumes:
- ./data:/app/data # Persist prompt library and history metaai-free-api:
build: ./services/metaai-api
container_name: metaai-free-api
restart: unless-stopped
ports:
- "8000:8000"

View file

@ -1,16 +1,18 @@
import { dirname } from "path"; import { defineConfig, globalIgnores } from "eslint/config";
import { fileURLToPath } from "url"; import nextVitals from "eslint-config-next/core-web-vitals";
import { FlatCompat } from "@eslint/eslintrc"; import nextTs from "eslint-config-next/typescript";
const __filename = fileURLToPath(import.meta.url); const eslintConfig = defineConfig([
const __dirname = dirname(__filename); ...nextVitals,
...nextTs,
const compat = new FlatCompat({ // Override default ignores of eslint-config-next.
baseDirectory: __dirname, globalIgnores([
}); // Default ignores of eslint-config-next:
".next/**",
const eslintConfig = [ "out/**",
...compat.extends("next/core-web-vitals", "next/typescript"), "build/**",
]; "next-env.d.ts",
]),
]);
export default eslintConfig; export default eslintConfig;

View file

@ -1,236 +0,0 @@
/**
* API Client for FastAPI Backend
*
* Centralized API calls to the FastAPI backend.
* Used by frontend components to call the new Python backend.
*/
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// Types
export interface GenerateParams {
prompt: string;
aspectRatio?: string;
refs?: { subject?: string | string[]; scene?: string | string[]; style?: string | string[] };
preciseMode?: boolean;
imageCount?: number;
cookies: string;
}
export interface GeneratedImage {
data: string;
index?: number;
prompt: string;
aspectRatio: string;
}
export interface VideoParams {
prompt: string;
imageBase64?: string;
imageGenerationId?: string;
cookies: string;
}
export interface ReferenceUploadParams {
imageBase64: string;
mimeType: string;
category: string;
cookies: string;
}
export interface MetaGenerateParams {
prompt: string;
cookies?: string;
imageCount?: number;
aspectRatio?: string;
useMetaFreeWrapper?: boolean;
metaFreeWrapperUrl?: string;
}
// Helper to handle API responses
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || error.detail || `HTTP ${response.status}`);
}
return response.json();
}
/**
* Generate images using Whisk API
*/
export async function generateImages(params: GenerateParams): Promise<{ images: GeneratedImage[] }> {
const response = await fetch(`${API_BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Generate video using Whisk API
*/
export async function generateVideo(params: VideoParams): Promise<{
success: boolean;
id?: string;
url?: string;
status?: string;
}> {
const response = await fetch(`${API_BASE}/video/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Upload reference image
*/
export async function uploadReference(params: ReferenceUploadParams): Promise<{
success: boolean;
id: string;
}> {
const response = await fetch(`${API_BASE}/references/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Generate images using Meta AI
*/
export async function generateMetaImages(params: MetaGenerateParams): Promise<{
success: boolean;
images: Array<{
data?: string;
url?: string;
prompt: string;
model: string;
aspectRatio: string;
}>;
}> {
const response = await fetch(`${API_BASE}/meta/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return handleResponse(response);
}
/**
* Get prompts from library
*/
export async function getPrompts(): Promise<{
prompts: any[];
last_updated: string | null;
lastSync: number | null;
categories: Record<string, string[]>;
total_count: number;
sources: string[];
}> {
const response = await fetch(`${API_BASE}/prompts`);
return handleResponse(response);
}
/**
* Sync prompts from sources
*/
export async function syncPrompts(): Promise<{
success: boolean;
count: number;
added: number;
}> {
const response = await fetch(`${API_BASE}/prompts/sync`, {
method: 'POST'
});
return handleResponse(response);
}
/**
* Track prompt usage
*/
export async function trackPromptUse(promptId: number): Promise<{
success: boolean;
promptId: number;
useCount: number;
lastUsedAt: number;
}> {
const response = await fetch(`${API_BASE}/prompts/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ promptId })
});
return handleResponse(response);
}
/**
* Get upload history
*/
export async function getHistory(category?: string): Promise<{
history: Array<{
id: string;
url: string;
originalName: string;
category: string;
mediaId?: string;
createdAt?: number;
}>;
}> {
const url = category ? `${API_BASE}/history?category=${category}` : `${API_BASE}/history`;
const response = await fetch(url);
return handleResponse(response);
}
/**
* Upload to history
*/
export async function uploadToHistory(file: File, category: string, cookies?: string): Promise<{
id: string;
url: string;
originalName: string;
category: string;
mediaId?: string;
}> {
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (cookies) formData.append('cookies', cookies);
const response = await fetch(`${API_BASE}/history`, {
method: 'POST',
body: formData
});
return handleResponse(response);
}
/**
* Delete history item
*/
export async function deleteHistoryItem(itemId: string): Promise<{ success: boolean }> {
const response = await fetch(`${API_BASE}/history/${itemId}`, {
method: 'DELETE'
});
return handleResponse(response);
}
/**
* Clear all history
*/
export async function clearHistory(): Promise<{ success: boolean }> {
const response = await fetch(`${API_BASE}/history`, {
method: 'DELETE'
});
return handleResponse(response);
}
/**
* Health check
*/
export async function healthCheck(): Promise<{ status: string; service: string; version: string }> {
const response = await fetch(`${API_BASE}/health`);
return handleResponse(response);
}

View file

@ -1,4 +1,36 @@
import { Prompt } from '@/lib/types'; import { Prompt } from '@/lib/types';
import fs from 'fs/promises';
import path from 'path';
export class HabuCrawler {
async crawl(): Promise<Prompt[]> {
console.log("[HabuCrawler] Reading from local data...");
const filePath = path.join(process.cwd(), 'data', 'habu_prompts.json');
try {
const data = await fs.readFile(filePath, 'utf-8');
const habuPrompts = JSON.parse(data);
return habuPrompts.map((p: any) => ({
id: 0, // Will be overwritten by sync service
title: p.name || 'Untitled Habu Prompt',
prompt: p.prompt,
category: 'Habu', // Default category since mapping is unknown
category_type: 'style',
description: p.prompt ? (p.prompt.substring(0, 150) + (p.prompt.length > 150 ? '...' : '')) : '',
images: p.imageUrl ? [p.imageUrl] : [],
author: 'Habu',
source: 'habu',
source_url: `https://taoanhez.com/#/prompt-library/${p.id}`,
createdAt: p.createdAt ? new Date(p.createdAt).getTime() : Date.now(),
useCount: 0
}));
} catch (e) {
console.error("[HabuCrawler] Error reading habu data", e);
return [];
}
}
}
export class JimmyLvCrawler { export class JimmyLvCrawler {
async crawl(): Promise<Prompt[]> { async crawl(): Promise<Prompt[]> {

View file

@ -1,7 +1,7 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { Prompt, PromptCache } from '@/lib/types'; import { Prompt, PromptCache } from '@/lib/types';
import { JimmyLvCrawler, YouMindCrawler, ZeroLuCrawler } from '@/lib/crawler'; import { JimmyLvCrawler, YouMindCrawler, ZeroLuCrawler, HabuCrawler } from '@/lib/crawler';
const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json'); const DATA_FILE = path.join(process.cwd(), 'data', 'prompts.json');
@ -21,15 +21,17 @@ export async function syncPromptsService(): Promise<{ success: boolean, count: n
const jimmyCrawler = new JimmyLvCrawler(); const jimmyCrawler = new JimmyLvCrawler();
const youMindCrawler = new YouMindCrawler(); const youMindCrawler = new YouMindCrawler();
const zeroLuCrawler = new ZeroLuCrawler(); const zeroLuCrawler = new ZeroLuCrawler();
const habuCrawler = new HabuCrawler();
const [jimmyPrompts, youMindPrompts, zeroLuPrompts] = await Promise.all([ const [jimmyPrompts, youMindPrompts, zeroLuPrompts, habuPrompts] = await Promise.all([
jimmyCrawler.crawl(), jimmyCrawler.crawl(),
youMindCrawler.crawl(), youMindCrawler.crawl(),
zeroLuCrawler.crawl() zeroLuCrawler.crawl(),
habuCrawler.crawl()
]); ]);
const crawledPrompts = [...jimmyPrompts, ...youMindPrompts, ...zeroLuPrompts]; const crawledPrompts = [...jimmyPrompts, ...youMindPrompts, ...zeroLuPrompts, ...habuPrompts];
console.log(`[SyncService] Total crawled ${crawledPrompts.length} prompts (Jimmy: ${jimmyPrompts.length}, YouMind: ${youMindPrompts.length}, ZeroLu: ${zeroLuPrompts.length}).`); console.log(`[SyncService] Total crawled ${crawledPrompts.length} prompts (Jimmy: ${jimmyPrompts.length}, YouMind: ${youMindPrompts.length}, ZeroLu: ${zeroLuPrompts.length}, Habu: ${habuPrompts.length}).`);
// 2. Read existing // 2. Read existing
const cache = await getPrompts(); const cache = await getPrompts();

View file

@ -84,7 +84,7 @@ export class WhiskClient {
if (c.name && c.value) cookies[c.name] = c.value; if (c.name && c.value) cookies[c.name] = c.value;
} }
return cookies; return cookies;
} catch { /* ignore */ } } catch (e) { /* ignore */ }
} }
// Try header string // Try header string
@ -172,7 +172,7 @@ export class WhiskClient {
prompt: string, prompt: string,
aspectRatio: string = "1:1", aspectRatio: string = "1:1",
refs: { subject?: string | string[]; scene?: string | string[]; style?: string | string[] } = {}, refs: { subject?: string | string[]; scene?: string | string[]; style?: string | string[] } = {},
_preciseMode: boolean = false preciseMode: boolean = false
): Promise<GeneratedImage[]> { ): Promise<GeneratedImage[]> {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
@ -216,7 +216,7 @@ export class WhiskClient {
seed: seed, seed: seed,
prompt: prompt, prompt: prompt,
mediaCategory: "MEDIA_CATEGORY_BOARD" mediaCategory: "MEDIA_CATEGORY_BOARD"
} as any; // eslint-disable-line @typescript-eslint/no-explicit-any } as any;
} else { } else {
// Image-to-Image (Recipe) - uses runImageRecipe endpoint // Image-to-Image (Recipe) - uses runImageRecipe endpoint
// Uses recipeMediaInputs array with caption and mediaInput for each ref // Uses recipeMediaInputs array with caption and mediaInput for each ref
@ -254,7 +254,7 @@ export class WhiskClient {
userInstruction: prompt, // Note: uses userInstruction instead of prompt userInstruction: prompt, // Note: uses userInstruction instead of prompt
recipeMediaInputs: recipeMediaInputs recipeMediaInputs: recipeMediaInputs
// Note: preciseMode field name TBD - needs API discovery // Note: preciseMode field name TBD - needs API discovery
} as any; // eslint-disable-line @typescript-eslint/no-explicit-any } as any;
} }
console.log(`Generating: "${prompt.substring(0, 30)}..." (Refs: ${mediaInputs.length})`); console.log(`Generating: "${prompt.substring(0, 30)}..." (Refs: ${mediaInputs.length})`);
@ -535,7 +535,7 @@ export class WhiskClient {
// IN_PROGRESS, PENDING, PROCESSING, RUNNING - continue polling // IN_PROGRESS, PENDING, PROCESSING, RUNNING - continue polling
console.log(`Video status: ${status} - continuing to poll...`); console.log(`Video status: ${status} - continuing to poll...`);
} }
} catch (e: any) { // eslint-disable-line @typescript-eslint/no-explicit-any } catch (e: any) {
// Check if this is a logical failure (should not retry) vs network error (should retry) // Check if this is a logical failure (should not retry) vs network error (should retry)
if (e.message?.includes('Video generation failed:') || if (e.message?.includes('Video generation failed:') ||
e.message?.includes('NCII') || e.message?.includes('NCII') ||

View file

@ -3,12 +3,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: "standalone", output: "standalone",
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
}; };
export default nextConfig; export default nextConfig;

10
package-lock.json generated
View file

@ -1947,6 +1947,7 @@
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -1964,6 +1965,7 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -2030,6 +2032,7 @@
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.51.0", "@typescript-eslint/types": "8.51.0",
@ -2619,6 +2622,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3644,6 +3648,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3817,6 +3822,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@ -6268,6 +6274,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@ -6280,6 +6287,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@ -7101,6 +7109,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -7280,6 +7289,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View file

@ -1,6 +1,6 @@
{ {
"name": "v2_temp", "name": "v2_temp",
"version": "0.1.0", "version": "2.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@ -33,4 +33,4 @@
"typescript": "^5", "typescript": "^5",
"vitest": "^1.0.0" "vitest": "^1.0.0"
} }
} }

BIN
public/images/prompts/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Some files were not shown because too many files have changed in this diff Show more