Compare commits

..

1 commit
v3.1.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 5486 deletions

View file

@ -1,95 +1,55 @@
# Stage 1: Build Next.js frontend
FROM node:20-alpine AS frontend-builder
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
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 . .
# 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
# Stage 2: Build Python backend
FROM python:3.11-slim AS backend-builder
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 supervisor
FROM python:3.11-slim AS runner
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Install Node.js and supervisor
RUN apt-get update && apt-get install -y \
curl \
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/*
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
# Create non-root user
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup appuser
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy Next.js standalone build
COPY --from=frontend-builder /app/public ./public
COPY --from=frontend-builder /app/.next/standalone ./
COPY --from=frontend-builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy Python backend
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=backend-builder /backend ./backend
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy data directory for prompts
COPY --from=frontend-builder /app/data ./data
# Automatically leverage output traces to reduce image size
# 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 supervisor config
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
USER nextjs
[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
EXPOSE 3000
[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
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Set permissions
RUN chown -R appuser:appgroup /app /var/log/supervisor
# Expose ports
EXPOSE 3000 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000 && curl -f http://localhost:8000/health || exit 1
# Run supervisor
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
CMD ["node", "server.js"]

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**.
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
A modern, lightweight AI Image Generator powered by Google ImageFX (Whisk), Grok, and Meta AI. Built with Next.js 14, TypeScript, and Tailwind CSS.
## 🚀 Quick Start
### Prerequisites
- Node.js 18+
- Python 3.11+
- Whisk/Grok/Meta Cookies (from respective services)
### Installation
```bash
# Install frontend dependencies
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
**Terminal 1 - Backend:**
```bash
cd backend
source venv/bin/activate
uvicorn main:app --reload --port 8000
```
**Terminal 2 - Frontend:**
```bash
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)
### Using Docker Compose (Recommended)
@ -60,53 +28,38 @@ docker-compose up -d
### Using Docker CLI
```bash
# Pull from registry
docker pull git.khoavo.myds.me/vndangkhoa/apix:v3
# Build
docker build -t kv-pix:latest .
# 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
```
┌─────────────────┐ 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/)
- **Framework**: [Next.js 14](https://nextjs.org/) (App Router)
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) + Custom Components
- **State**: [Zustand](https://github.com/pmndrs/zustand)
- **Icons**: [Lucide React](https://lucide.dev/)
### Project Structure
```
├── app/ # Next.js pages and (legacy) API routes
├── backend/ # FastAPI Python backend
│ ├── main.py
│ ├── routers/ # API endpoints
│ ├── services/ # Business logic (whisk_client, meta_client)
│ └── models/ # Pydantic request/response models
├── components/ # React UI components
├── lib/ # Frontend utilities and API client
├── data/ # Prompt library JSON
└── public/ # Static assets
app/ # Pages and API Routes
components/ # React UI Components
lib/ # Core Logic (whisk-client, meta-client, grok-client)
data/ # Prompt library JSON
public/ # Static assets
```
## ✨ Features
- **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
- **Prompt Library**: Curated prompts organized by categories and sources
- **Upload History**: Reuse previously uploaded reference images
- **Reference Chips**: Drag-and-drop references for Subject/Scene/Style
- **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
## 🍪 Cookie Configuration
1. Go to the respective service:
- Whisk: [ImageFX](https://labs.google/fx/tools/image-fx)
- Grok: [grok.com](https://grok.com)
- 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).
4. Paste into the **Settings** menu in the app.

View file

@ -25,78 +25,50 @@
@layer base {
:root {
/* Light Mode - Slate Refresh */
--background: #F8FAFC;
/* Slate-50 */
--foreground: #0F172A;
/* Slate-900 */
/* Light Mode (from Reference) */
--background: #F3F4F6;
--foreground: #111827;
--card: #FFFFFF;
--card-foreground: #1E293B;
/* Slate-800 */
--popover: rgba(255, 255, 255, 0.8);
--popover-foreground: #0F172A;
--primary: #7C3AED;
/* Violet-600 */
--primary-foreground: #FFFFFF;
--secondary: #E2E8F0;
/* Slate-200 */
--secondary-foreground: #0F172A;
--muted: #F1F5F9;
/* Slate-100 */
--muted-foreground: #64748B;
/* Slate-500 */
--accent: #E2E8F0;
--accent-foreground: #0F172A;
--card-foreground: #111827;
--popover: #FFFFFF;
--popover-foreground: #111827;
--primary: #FFD700;
--primary-foreground: #111827;
--secondary: #E5E7EB;
--secondary-foreground: #111827;
--muted: #E5E7EB;
--muted-foreground: #6B7280;
--accent: #FFD700;
--accent-foreground: #111827;
--destructive: #EF4444;
--destructive-foreground: #FFFFFF;
--border: #E2E8F0;
/* Slate-200 */
--input: #F1F5F9;
--ring: rgba(124, 58, 237, 0.4);
--radius: 1.25rem;
/* Modern rounded corners (20px) */
/* Spacing & Effects */
--header-height: 4rem;
--nav-height: 5rem;
--shadow-soft: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.07), 0 4px 6px -4px rgb(0 0 0 / 0.07);
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--destructive-foreground: #FEF2F2;
--border: #E5E7EB;
--input: #E5E7EB;
--ring: #FFD700;
--radius: 0.5rem;
}
.dark {
/* Dark Mode - Deep Navy Slate */
--background: #0F172A;
/* Slate-900 */
--foreground: #F8FAFC;
/* Slate-50 */
--card: #1E293B;
/* Slate-800 */
--card-foreground: #F1F5F9;
--popover: rgba(30, 41, 59, 0.8);
--popover-foreground: #F8FAFC;
--primary: #8B5CF6;
/* Violet-500 (brighter for dark) */
--primary-foreground: #FFFFFF;
--secondary: #334155;
/* Slate-700 */
--secondary-foreground: #F8FAFC;
--muted: #334155;
/* Slate-700 */
--muted-foreground: #94A3B8;
/* Slate-400 */
--accent: #334155;
--accent-foreground: #F8FAFC;
--destructive: #F87171;
--destructive-foreground: #FFFFFF;
--border: #334155;
/* Slate-700 */
--input: #0F172A;
--ring: rgba(139, 92, 246, 0.5);
--shadow-soft: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4);
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
/* Dark Mode (from Reference) */
--background: #1F2937;
--foreground: #F9FAFB;
--card: #374151;
--card-foreground: #F9FAFB;
--popover: #374151;
--popover-foreground: #F9FAFB;
--primary: #FFD700;
--primary-foreground: #111827;
--secondary: #4B5563;
--secondary-foreground: #F9FAFB;
--muted: #4B5563;
--muted-foreground: #9CA3AF;
--accent: #FFD700;
--accent-foreground: #111827;
--destructive: #EF4444;
--destructive-foreground: #FEF2F2;
--border: #4B5563;
--input: #4B5563;
--ring: #FFD700;
}
* {
@ -104,14 +76,13 @@
}
body {
@apply bg-background text-foreground transition-colors duration-300;
@apply bg-background text-foreground;
}
}
/* Custom Scrollbar - Minimal & Modern */
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
}
::-webkit-scrollbar-track {
@ -119,32 +90,10 @@
}
::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 10px;
background: #374151;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* Utility Classes for Modern Look */
.glass-panel {
@apply bg-popover backdrop-blur-xl border border-white/10 dark:border-white/5;
}
.shadow-premium {
box-shadow: var(--shadow-md);
}
.shadow-premium-lg {
box-shadow: var(--shadow-lg);
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
background: #4B5563;
}

View file

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

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect } from 'react';
import { useStore } from '@/lib/store';
import { Navbar } from "@/components/Navbar";
import { Gallery } from "@/components/Gallery";
@ -8,8 +9,9 @@ import { PromptHero } from "@/components/PromptHero";
import { Settings } from "@/components/Settings";
import { PromptLibrary } from "@/components/PromptLibrary";
import { UploadHistory } from "@/components/UploadHistory";
import { CookieExpiredDialog } from "@/components/CookieExpiredDialog";
import { BottomNav } from "@/components/BottomNav";
export default function Home() {
const { currentView, setCurrentView, loadGallery } = useStore();
@ -18,29 +20,17 @@ export default function Home() {
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 (
<div className="flex h-[100dvh] w-full bg-background text-foreground overflow-hidden font-sans flex-col relative">
{/* Top Navbar - Mobile Header */}
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden font-sans flex-col">
{/* Top Navbar */}
<Navbar />
{/* 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 */}
<div className="flex-1 overflow-y-auto w-full scroll-smooth no-scrollbar pt-16 pb-24 md:pb-0">
<div className="w-full max-w-lg md:max-w-7xl mx-auto p-4 md:p-6 min-h-full">
<div className="flex-1 overflow-y-auto w-full scroll-smooth">
<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 */}
{currentView === 'gallery' && (
@ -62,8 +52,6 @@ export default function Home() {
</div>
</main>
{/* Bottom Navigation (Mobile & Desktop App-like) */}
<BottomNav currentTab={getActiveTab()} onTabChange={handleTabChange} />
<CookieExpiredDialog />
</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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="relative w-full max-w-[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="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-md bg-[#18181B] border border-white/10 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-200 overflow-hidden">
{/* Top Glow/Gradient */}
<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" />
{/* Decorative header background */}
<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">
{/* Close Button */}
<div className="relative p-6 px-8 flex flex-col items-center text-center">
<button
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>
{/* Cookie Icon Container */}
<div className="relative mt-4 mb-8">
{/* Glow effect */}
<div className="absolute inset-0 bg-amber-500/20 blur-2xl rounded-full" />
<div className="relative h-20 w-20 rounded-full bg-[#1A1A1D] border-2 border-amber-500/30 flex items-center justify-center shadow-[inset_0_0_20px_rgba(245,158,11,0.1)]">
<Cookie className="h-10 w-10 text-amber-500" />
</div>
<div className="h-16 w-16 mb-6 rounded-full bg-amber-500/10 flex items-center justify-center ring-1 ring-amber-500/20 shadow-lg shadow-amber-900/20">
<Cookie className="h-8 w-8 text-amber-500" />
</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]">
Your <span className="text-white font-bold">{providerName}</span> session has timed out.
<p className="text-muted-foreground text-sm mb-6 leading-relaxed">
Your <span className="text-white font-medium">{providerName}</span> session has timed out.
To continue generating images, please refresh your cookies.
</p>
<div className="w-full space-y-3">
<button
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
</button>
@ -70,9 +64,9 @@ export function CookieExpiredDialog() {
href={providerUrl}
target="_blank"
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}
</a>
</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 { EditPromptModal } from './EditPromptModal';
// FastAPI backend URL
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// Helper function to get proper image src (handles URLs vs base64)
const getImageSrc = (data: string): string => {
if (!data) return '';
@ -40,15 +37,6 @@ export function Gallery() {
const [videoPromptValue, setVideoPromptValue] = React.useState('');
const [useSourceImage, setUseSourceImage] = React.useState(true);
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(() => {
if (selectedIndex !== null && gallery[selectedIndex]) {
@ -109,12 +97,11 @@ export function Gallery() {
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
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;
}
} 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -141,7 +128,7 @@ export function Gallery() {
} else {
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);
let errorMessage = error.message || 'Video generation failed';
if (errorMessage.includes('401') || errorMessage.includes('cookies') || errorMessage.includes('expired')) {
@ -171,7 +158,7 @@ export function Gallery() {
setIsGeneratingWhiskVideo(true);
try {
const res = await fetch(`${API_BASE}/video/generate`, {
const res = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -238,12 +225,11 @@ export function Gallery() {
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
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;
}
} 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -258,7 +244,6 @@ export function Gallery() {
if (data.success && data.images?.length > 0) {
// Add new images to gallery
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newImages = data.images.map((img: any) => ({
id: crypto.randomUUID(),
data: img.data, // Base64
@ -278,7 +263,7 @@ export function Gallery() {
} else {
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);
alert("Remix failed: " + e.message);
}
@ -292,7 +277,7 @@ export function Gallery() {
}
// 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -315,7 +300,7 @@ export function Gallery() {
if (options.keepStyle) refs.style = [uploadData.id];
// Generate new image with references
const res = await fetch(`${API_BASE}/generate`, {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -404,28 +389,25 @@ export function Gallery() {
return (
<div className="pb-32">
{/* 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>
<h2 className="text-xl font-extrabold text-foreground tracking-tight">{gallery.length} Creations</h2>
<p className="text-[10px] uppercase font-bold tracking-widest text-muted-foreground mt-0.5">Your library of generated images</p>
<h2 className="text-xl font-semibold">{gallery.length} Generated Images</h2>
</div>
{gallery.length > 0 && (
<button
onClick={handleClearAll}
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-3.5 w-3.5" />
<span>Reset</span>
</button>
)}
<button
onClick={handleClearAll}
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-4 w-4" />
<span>Clear All</span>
</button>
</div>
{/* Videos Section - Show generated videos */}
{videos.length > 0 && (
<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" />
<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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{videos.map((vid) => (
@ -434,7 +416,7 @@ export function Gallery() {
layout
initial={{ opacity: 0, scale: 0.9 }}
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
src={vid.url}
@ -445,13 +427,13 @@ export function Gallery() {
/>
<button
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"
>
<X className="h-4 w-4" />
</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">
<p className="text-white text-xs line-clamp-1 font-medium">{vid.prompt}</p>
<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">{vid.prompt}</p>
</div>
</motion.div>
))}
@ -460,12 +442,15 @@ export function Gallery() {
)}
{/* 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 */}
{isGenerating && (
<>
{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>
))}
</>
@ -475,44 +460,45 @@ export function Gallery() {
{gallery.map((img, i) => (
<motion.div
key={img.id || `video-${i}`}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
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"
onClick={() => setSelectedIndex(i)}
className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm"
>
<img
src={getImageSrc(img.data)}
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"
/>
{/* Overlay Gradient */}
<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" />
{/* Provider Tag */}
{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 */}
<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) */}
{/* Delete button - Top right */}
<button
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" />
</button>
{/* Caption - Glass Style */}
<div className="absolute bottom-4 left-4 right-4 p-3 glass-panel rounded-2xl border-white/10 md:opacity-0 md:group-hover:opacity-100 translate-y-2 md:group-hover:translate-y-0 transition-all duration-500">
<p className="text-white text-[10px] font-bold line-clamp-2 leading-tight tracking-tight">
{img.prompt}
</p>
{/* Hover Overlay - Simplified: just show prompt */}
<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-xs line-clamp-2">{img.prompt}</p>
</div>
</motion.div>
))}
@ -526,280 +512,220 @@ export function Gallery() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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)}
>
{/* Top Controls Bar */}
<div className="absolute top-0 inset-x-0 h-20 flex items-center justify-between px-6 z-[120] pointer-events-none">
<div className="pointer-events-auto">
<button
onClick={() => setShowControls(!showControls)}
className={cn(
"p-3 rounded-full transition-all border shadow-xl backdrop-blur-md active:scale-95",
showControls
? "bg-primary text-white border-primary/50"
: "bg-black/60 text-white border-white/20 hover:bg-black/80"
)}
title={showControls ? "Hide Controls" : "Show Controls"}
>
<Sparkles className={cn("h-5 w-5", showControls ? "animate-pulse" : "")} />
</button>
</div>
{/* Close Button */}
<button
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={() => setSelectedIndex(null)}
>
<X className="h-5 w-5" />
</button>
{/* Navigation Buttons */}
{selectedIndex > 0 && (
<button
className="p-3 bg-background/50 hover:bg-background rounded-full text-foreground transition-colors border border-border shadow-xl backdrop-blur-md pointer-events-auto"
onClick={() => setSelectedIndex(null)}
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
>
<X className="h-5 w-5" />
<ChevronLeft className="h-6 w-6 md:h-8 md:w-8" />
</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 */}
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
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()}
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) */}
<div className="flex-1 flex items-center justify-center min-h-0 relative group/arrows p-4 md:p-12">
<motion.img
layout
{/* Left: Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<img
src={getImageSrc(selectedImage.data)}
alt={selectedImage.prompt}
className={cn(
"max-w-full max-h-full object-contain rounded-2xl shadow-2xl transition-all duration-500",
showControls ? "md:scale-[0.9] scale-[0.85] translate-y-[-10%] md:translate-y-0" : "scale-100"
)}
className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
/>
{/* 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>
{/* Right: Controls Panel (Retractable) */}
<AnimatePresence>
{showControls && (
<motion.div
initial={isMobile ? { y: "100%", opacity: 0 } : { x: "100%", opacity: 0 }}
animate={{ x: 0, y: 0, opacity: 1 }}
exit={isMobile ? { y: "100%", opacity: 0 } : { x: "100%", opacity: 0 }}
transition={{ type: "spring", damping: 30, stiffness: 300, mass: 0.8 }}
drag={isMobile ? "y" : false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={(_, info) => {
if (isMobile && info.offset.y > 100) {
setShowControls(false);
{/* Right: Controls Panel */}
<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">
{/* Provider Badge */}
{selectedImage.provider && (
<div className={cn(
"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" :
"bg-amber-500/20 text-amber-300 border border-amber-500/30"
)}>
{selectedImage.provider}
</div>
)}
{/* 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) */}
<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" />
<Trash2 className="h-3.5 w-3.5" />
<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">
{/* Provider Badge */}
{selectedImage.provider && (
<div className={cn(
"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" :
"bg-amber-500/20 text-amber-300 border border-amber-500/30"
)}>
{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>
{/* Image Info */}
<div className="mt-auto pt-3 border-t border-white/10 text-xs text-white/40 space-y-1">
{selectedImage.aspectRatio && (
<p>Aspect Ratio: {selectedImage.aspectRatio}</p>
)}
<p>Image {selectedIndex + 1} of {gallery.length}</p>
</div>
</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";
import React, { useEffect } from 'react';
import React from 'react';
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 { motion } from 'framer-motion';
import { useTheme } from '@/components/theme-provider';
export function Navbar() {
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 = [
{ id: 'gallery', label: 'Create', icon: Sparkles },
@ -29,11 +17,11 @@ export function Navbar() {
return (
<>
<div className="fixed top-0 left-0 right-0 z-50 glass-panel border-b border-border shadow-soft md:hidden">
{/* Visual Highlight Line */}
<div className="h-0.5 w-full bg-gradient-to-r from-transparent via-primary/50 to-transparent" />
<div className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border">
{/* Yellow Accent Line */}
<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 */}
<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">
@ -43,7 +31,7 @@ export function Navbar() {
</div>
{/* 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) => (
<button
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",
currentView === item.id
? "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" />
@ -66,12 +54,6 @@ export function Navbar() {
{/* Right Actions */}
<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
onClick={() => setCurrentView('settings')}
className={cn(
@ -84,16 +66,62 @@ export function Navbar() {
<Settings className="h-5 w-5" />
</button>
<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">
<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">
<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-xs font-bold">
KV
</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>
</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 { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Brain, Settings, Settings2 } from "lucide-react";
// FastAPI backend URL
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const IMAGE_COUNTS = [1, 2, 4];
export function PromptHero() {
@ -129,7 +126,7 @@ export function PromptHero() {
const subjectRef = references.subject?.[0];
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -148,7 +145,7 @@ export function PromptHero() {
style: references.style?.map(r => r.id) || [],
};
res = await fetch(`${API_BASE}/generate`, {
res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -320,7 +317,7 @@ export function PromptHero() {
// If Whisk, upload to backend to get ID
if (!settings.provider || settings.provider === 'whisk') {
try {
const res = await fetch(`${API_BASE}/references/upload`, {
const res = await fetch('/api/references/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -437,25 +434,28 @@ export function PromptHero() {
);
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 */}
{errorNotification && (
<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'
? "bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400"
: "bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"
? "bg-amber-500/10 border-amber-500/30 text-amber-200"
: "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">
<p className="text-sm font-semibold">
{errorNotification.type === 'warning' ? 'Content Moderation' : 'Generation Error'}
<p className="text-sm font-medium">
{errorNotification.type === 'warning' ? '⚠️ Content Moderation' : 'Generation Error'}
</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>
<button
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" />
</button>
@ -463,163 +463,300 @@ export function PromptHero() {
)}
<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",
isGenerating && "ring-2 ring-primary/20 border-primary/50 shadow-lg shadow-primary/10"
"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-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 */}
<div className="flex items-center justify-between mb-8 relative z-10">
<div className="flex items-center space-x-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">
{settings.provider === 'meta' ? <Brain className="h-5 w-5" /> : <Sparkles className="h-5 w-5" />}
{/* Header / Title + Provider Toggle */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-amber-500/20 to-purple-600/20 border border-white/5 flex items-center justify-center">
{settings.provider === 'meta' ? (
<Brain className="h-4 w-4 text-blue-400" />
) : (
<Sparkles className="h-4 w-4 text-amber-300" />
)}
</div>
<div>
<h2 className="font-extrabold text-xl text-foreground tracking-tight">Create</h2>
<span className="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">
Powered by <span className="text-secondary">{settings.provider === 'meta' ? 'Meta AI' : 'Google Whisk'}</span>
</span>
<h2 className="text-base font-bold text-white tracking-tight flex items-center gap-2">
Create
<span className="text-[10px] font-medium text-white/40 border-l border-white/10 pl-2">
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 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
onClick={() => setSettings({ provider: settings.provider === 'meta' ? 'whisk' : 'meta' })}
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"
title="Switch Provider"
onClick={() => setSettings({ provider: 'whisk' })}
className={cn(
"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" />
<span>Switch</span>
<Sparkles className="h-3 w-3" />
<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>
</div>
</div>
{/* 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
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
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="What's on your mind? Describe your vision..."
placeholder="Describe your imagination..."
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>
{/* Reference Upload Grid */}
<div className="grid grid-cols-3 gap-4 mb-6 relative z-10">
{((settings.provider === 'meta' ? ['subject'] : ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
const refs = references[cat] || [];
const hasRefs = refs.length > 0;
const isUploading = uploadingRefs[cat];
{/* Controls Area */}
<div className="flex flex-col md:flex-row items-center justify-between gap-3 pt-1">
return (
<button
key={cat}
onClick={() => toggleReference(cat)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, cat)}
className={cn(
"flex flex-col items-center justify-center gap-2 py-4 rounded-2xl border transition-all relative overflow-hidden group/btn shadow-soft",
hasRefs
? "bg-primary/5 border-primary/30"
: "bg-muted/50 hover:bg-muted border-border/50"
)}
>
{isUploading ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
) : hasRefs ? (
<div className="relative pt-1">
<div className="flex -space-x-2.5 justify-center">
{refs.slice(0, 3).map((ref, idx) => (
<img key={ref.id} src={ref.thumbnail} className="w-8 h-8 rounded-full object-cover ring-2 ring-background shadow-md" style={{ zIndex: 10 - idx }} />
))}
</div>
<div className="absolute -top-1 -right-3 bg-secondary text-secondary-foreground text-[10px] font-black px-1.5 py-0.5 rounded-full shadow-sm">{refs.length}</div>
{/* Left Controls: References */}
{/* For Meta AI: Only Subject is enabled (for video generation), Scene/Style disabled */}
<div className="flex flex-wrap gap-2">
{((settings.provider === 'meta'
? ['subject']
: ['subject', 'scene', 'style']) as ReferenceCategory[]).map((cat) => {
const refs = references[cat] || [];
const hasRefs = refs.length > 0;
const isUploading = uploadingRefs[cat];
return (
<div key={cat} className="relative group">
<button
onClick={() => toggleReference(cat)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, cat)}
title={settings.provider === 'meta' && cat === 'subject'
? "Upload image to animate into video"
: undefined}
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",
hasRefs
? "bg-purple-500/10 text-purple-200 border-purple-500/30 hover:bg-purple-500/20"
: "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10",
isUploading && "animate-pulse cursor-wait"
)}
>
{isUploading ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : hasRefs ? (
<div className="flex -space-x-1.5">
{refs.slice(0, 4).map((ref, idx) => (
<img
key={ref.id}
src={ref.thumbnail}
alt=""
className="h-4 w-4 rounded-sm object-cover ring-1 ring-white/20"
style={{ zIndex: 10 - idx }}
/>
))}
</div>
) : (
<Upload className="h-3 w-3" />
)}
<span className="capitalize tracking-wide">{cat}</span>
{refs.length > 0 && (
<span className="text-[9px] bg-purple-500/30 text-purple-100 rounded-full px-1.5 h-3 flex items-center">{refs.length}</span>
)}
</button>
{/* 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 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>
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className={cn(
"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",
isGenerating && "animate-pulse"
)}
>
{isGenerating ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
<span>Generating...</span>
</>
) : (
<>
<Sparkles className="h-4 w-4 group-hover/gen:rotate-12 transition-transform" />
<span>Dream Big</span>
</>
)}
</button>
</div>
</div>
{/* Hidden file inputs for upload */}
<input
type="file"
ref={fileInputRefs.subject}
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFileInputChange(e, 'subject')}
/>
<input
type="file"
ref={fileInputRefs.scene}
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFileInputChange(e, 'scene')}
/>
<input
type="file"
ref={fileInputRefs.style}
accept="image/*"
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')} />
<input type="file" ref={fileInputRefs.style} accept="image/*" multiple className="hidden" onChange={(e) => handleFileInputChange(e, 'style')} />
{/* Right Controls: Settings & Generate */}
<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>
);
}

View file

@ -7,9 +7,6 @@ import { cn } from '@/lib/utils';
import { Prompt, PromptCache } from '@/lib/types';
import { motion, AnimatePresence } from 'framer-motion';
// FastAPI backend URL
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => void }) {
const { setPrompt, settings } = useStore();
const [prompts, setPrompts] = useState<Prompt[]>([]);
@ -20,23 +17,16 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
const [searchTerm, setSearchTerm] = useState('');
const [sortMode, setSortMode] = useState<'all' | 'latest' | 'history' | 'foryou'>('all');
const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const fetchPrompts = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/prompts`);
const res = await fetch('/api/prompts');
if (res.ok) {
const data: PromptCache = await res.json();
setPrompts(data.prompts);
} else {
throw new Error(`Server returned ${res.status}`);
}
} catch (error) {
console.error("Failed to fetch prompts", error);
setError("Unable to load the prompt library. Please check your connection.");
} finally {
setLoading(false);
}
@ -44,14 +34,12 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
const syncPrompts = async () => {
setLoading(true);
setError(null);
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');
await fetchPrompts();
} catch (error) {
console.error("Failed to sync prompts", error);
setError("Failed to sync new prompts from the community.");
} finally {
setLoading(false);
}
@ -73,7 +61,7 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
try {
console.log(`Requesting preview for: ${prompt.title}`);
const res = await fetch(`${API_BASE}/prompts/generate`, {
const res = await fetch('/api/prompts/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -121,7 +109,7 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
// Track usage
try {
await fetch(`${API_BASE}/prompts/use`, {
await fetch('/api/prompts/use', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id })
@ -176,137 +164,146 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
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();
// 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 uniqueSources = ['All', ...Array.from(new Set(prompts.map(p => p.source)))].filter(Boolean);
return (
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-10 pb-32">
<div className="flex flex-col items-center text-center gap-6">
<div className="flex flex-col items-center gap-3">
<div className="p-4 bg-primary/10 rounded-2xl text-primary shadow-sm">
<Sparkles className="h-8 w-8" />
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-8 pb-32">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-primary/10 rounded-xl text-primary">
<Sparkles className="h-6 w-6" />
</div>
<div>
<h2 className="text-3xl font-black tracking-tight">Prompt Library</h2>
<p className="text-muted-foreground text-sm font-medium">Curated inspiration from the community.</p>
<h2 className="text-2xl font-bold">Prompt Library</h2>
<p className="text-muted-foreground">Curated inspiration from the community.</p>
</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">
<div className="relative flex-1 w-full group">
<input
type="text"
placeholder="Search prompts..."
className="px-5 py-3 pl-12 pr-28 rounded-2xl bg-card border border-border/50 focus:border-primary focus:ring-4 focus:ring-primary/10 focus:outline-none w-full transition-all shadow-soft"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
</div>
{/* Compact Action Buttons inside search bar */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 p-1 bg-muted/50 rounded-xl border border-border/30">
<button
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>
<button
onClick={syncPrompts}
disabled={loading}
className="p-2 hover:bg-secondary rounded-full transition-colors"
title="Sync from GitHub"
>
<RefreshCw className={cn("h-5 w-5", loading && "animate-spin")} />
</button>
<input
type="text"
placeholder="Search prompts..."
className="px-4 py-2 rounded-lg bg-card border focus:border-primary focus:outline-none w-full md:w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{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 && (
<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" />
<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>
)}
{/* Smart Tabs */}
<div className="flex justify-center">
<div className="flex items-center gap-1 bg-muted/50 p-1.5 rounded-2xl border border-border/50 shadow-soft">
{(['all', 'latest', 'history', 'foryou'] as const).map(mode => (
<button
key={mode}
onClick={() => setSortMode(mode)}
className={cn(
"px-6 py-2.5 rounded-xl text-xs font-black transition-all capitalize uppercase tracking-tighter active:scale-95",
sortMode === mode
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
{mode === 'foryou' ? 'For You' : mode}
</button>
))}
</div>
<div className="flex items-center gap-1 bg-secondary/30 p-1 rounded-xl w-fit">
{(['all', 'latest', 'history', 'foryou'] as const).map(mode => (
<button
key={mode}
onClick={() => setSortMode(mode)}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
sortMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
{mode === 'foryou' ? 'For You' : mode}
</button>
))}
</div>
{/* Sub-Categories */}
{sortMode === 'all' && (
<div className="flex flex-wrap gap-2 justify-center max-w-4xl mx-auto">
{uniqueCategories.map(cat => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={cn(
"px-5 py-2 rounded-2xl text-xs font-bold transition-all border active:scale-95",
selectedCategory === cat
? "bg-secondary text-secondary-foreground border-transparent shadow-md"
: "bg-card hover:bg-muted text-muted-foreground border-border/50"
)}
>
{cat}
</button>
))}
<div className="flex flex-wrap gap-2 py-4 overflow-x-auto scrollbar-hide">
{(() => {
const priority = ['NAM', 'NỮ', 'SINH NHẬT', 'HALLOWEEN', 'NOEL', 'NEW YEAR', 'TRẺ EM', 'COUPLE', 'CHA - MẸ', 'MẸ BẦU', 'ĐẶC BIỆT'];
// Sort uniqueCategories (which only contains categories that exist in data)
const sortedCategories = uniqueCategories.sort((a, b) => {
if (a === 'All') return -1;
if (b === 'All') return 1;
const idxA = priority.indexOf(a);
const idxB = priority.indexOf(b);
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
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>
)}
{/* Source Filter */}
<div className="flex flex-wrap gap-3 items-center justify-center pt-2">
<span className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60 mr-1">Sources:</span>
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm font-medium text-muted-foreground mr-2">Sources:</span>
{uniqueSources.map(source => (
<button
key={source}
onClick={() => setSelectedSource(source)}
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
? "bg-primary/10 text-primary border-primary/20 shadow-sm"
: "bg-muted/30 hover:bg-muted text-muted-foreground/70 border-border/30"
? "bg-primary text-primary-foreground border-primary"
: "bg-card hover:bg-secondary text-muted-foreground border-secondary"
)}
>
{source}
@ -314,69 +311,137 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
))}
</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 ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence mode="popLayout">
{finalPrompts.map((p) => (
<motion.div
key={p.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
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">
<img
src={p.images[0]}
alt={p.title}
className="object-cover w-full h-full transition-transform group-hover:scale-105"
loading="lazy"
/>
</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>
)}
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence mode="popLayout">
{paginatedPrompts.map((p) => (
<motion.div
key={p.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
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">
<img
src={p.images[0]}
alt={p.title}
className="object-cover w-full h-full transition-transform group-hover:scale-105"
loading="lazy"
/>
</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>
)}
<div className="p-4 flex flex-col flex-1 gap-3">
<div className="flex justify-between items-start gap-2">
<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">
{p.source}
</span>
</div>
<div className="p-4 flex flex-col flex-1 gap-3">
<div className="flex justify-between items-start gap-2">
<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">
{p.source}
</span>
</div>
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 font-mono bg-secondary/30 p-2 rounded">
{p.prompt}
</p>
<p className="text-sm text-muted-foreground line-clamp-3 flex-1 font-mono bg-secondary/30 p-2 rounded">
{p.prompt}
</p>
<div className="flex items-center justify-between pt-2 border-t mt-auto">
<button
onClick={() => handleSelect(p)}
className="text-xs font-medium text-primary hover:underline flex items-center gap-1"
>
Use Prompt
</button>
<button
onClick={() => navigator.clipboard.writeText(p.prompt)}
className="p-1.5 text-muted-foreground hover:text-primary transition-colors"
title="Copy to clipboard"
>
<Copy className="h-4 w-4" />
</button>
<div className="flex items-center justify-between pt-2 border-t mt-auto">
<button
onClick={() => handleSelect(p)}
className="text-xs font-medium text-primary hover:underline flex items-center gap-1"
>
Use Prompt
</button>
<button
onClick={() => navigator.clipboard.writeText(p.prompt)}
className="p-1.5 text-muted-foreground hover:text-primary transition-colors"
title="Copy to clipboard"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</motion.div>
))}
</AnimatePresence>
</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 && (

View file

@ -2,11 +2,8 @@
import React from 'react';
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 { useTheme } from '@/components/theme-provider';
import { MobileCookieInstructions } from './MobileCookieInstructions';
type Provider = 'whisk' | 'meta';
@ -17,27 +14,6 @@ const providers: { id: Provider; name: string; icon: any; description: string }[
export function Settings() {
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
const [provider, setProvider] = React.useState<Provider>(settings.provider || 'whisk');
@ -47,8 +23,6 @@ export function Settings() {
const [metaCookies, setMetaCookies] = React.useState(settings.metaCookies || '');
const [facebookCookies, setFacebookCookies] = React.useState(settings.facebookCookies || '');
const [saved, setSaved] = React.useState(false);
const [whiskVerified, setWhiskVerified] = React.useState(false);
const [metaVerified, setMetaVerified] = React.useState(false);
const handleSave = () => {
setSettings({
@ -64,283 +38,143 @@ export function Settings() {
};
return (
<div className="space-y-8 pb-32">
{/* Header Section */}
<div className="px-2">
<h2 className="text-2xl font-black text-foreground tracking-tight">Settings</h2>
<p className="text-sm font-medium text-muted-foreground mt-1">Configure your AI preferences and API credentials.</p>
<div className="max-w-2xl mx-auto space-y-8 p-4 md:p-8">
<div>
<h2 className="text-2xl font-bold mb-2">Settings</h2>
<p className="text-muted-foreground">Configure your AI image generation provider.</p>
</div>
{/* General Preferences Card */}
<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="absolute top-0 right-0 -mr-12 -mt-12 w-48 h-48 bg-primary/5 rounded-full blur-2xl" />
<section className="space-y-6 relative z-10">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-2xl bg-primary/10 text-primary">
<Settings2 className="h-5 w-5" />
</div>
<h3 className="font-extrabold text-lg tracking-tight">Appearance</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<ThemeButton theme="light" icon={Sun} label="Light" />
<ThemeButton theme="dark" icon={Moon} label="Dark" />
<ThemeButton theme="system" icon={Monitor} label="System" />
</div>
</section>
<div className="border-t border-border/50" />
<section className="space-y-6 relative z-10">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-2xl bg-primary/10 text-primary">
<Sparkles className="h-5 w-5" />
</div>
<h3 className="font-extrabold text-lg tracking-tight">Default Engine</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{providers.map((p) => (
<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>
{/* Provider Selection */}
<div className="space-y-3">
<label className="text-sm font-medium">Image Generation Provider</label>
<div className="grid grid-cols-3 gap-3">
{providers.map((p) => (
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-primary text-white rounded-full text-xs font-black uppercase tracking-widest hover:bg-violet-700 shadow-lg shadow-primary/20 transition-all active:scale-95"
key={p.id}
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" />
{saved ? "Saved Configuration" : "Save Configuration"}
<p.icon className={cn(
"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>
))}
</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 className="grid grid-cols-1 gap-12">
{/* Whisk Settings */}
{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>
)}
{provider === 'meta' && (
<div className="space-y-4">
{/* Meta AI Settings */}
{provider === 'meta' && (
<div className="space-y-4 animate-in fade-in slide-in-from-right-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-blue-500" />
<h4 className="text-xs font-black uppercase tracking-widest text-foreground">Meta AI Configuration</h4>
{/* Advanced Settings (Hidden by default) */}
<details className="group mb-4">
<summary className="flex items-center gap-2 cursor-pointer text-xs text-white/40 hover:text-white/60 mb-2 select-none">
<Settings2 className="h-3 w-3" />
<span>Advanced Configuration</span>
</summary>
<div className="pl-4 border-l border-white/5 space-y-4 mb-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-secondary/30 border border-border/50">
<div className="space-y-0.5">
<label className="text-sm font-medium text-white/70">Use Free API Wrapper</label>
<p className="text-[10px] text-muted-foreground">Running locally via Docker</p>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs ${useMetaFreeWrapper ? "text-primary font-medium" : "text-muted-foreground"}`}>{useMetaFreeWrapper ? "ON" : "OFF"}</span>
<button
onClick={() => {
const isValid = metaCookies.length > 50 && facebookCookies.length > 50;
if (isValid) {
setMetaVerified(true);
setTimeout(() => setMetaVerified(false), 3000);
} else {
alert("Please ensure both Meta and Facebook cookies are pasted.");
}
}}
className={cn(
"px-3 py-1 text-[10px] font-bold rounded-lg border transition-all active:scale-95 flex items-center gap-1.5",
metaVerified
? "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400"
: "bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/20"
)}
onClick={() => setUseMetaFreeWrapper(!useMetaFreeWrapper)}
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"}`}
>
{metaVerified ? <Check className="h-3 w-3" /> : null}
{metaVerified ? "Verified" : "Verify Duo"}
<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"}`} />
</button>
</div>
</div>
{/* Advanced Configuration (Meta Specific) */}
<details className="group mb-4">
<summary className="flex items-center gap-2 cursor-pointer text-xs text-muted-foreground/50 hover:text-muted-foreground mb-4 select-none">
<Settings2 className="h-3 w-3" />
<span>Advanced Host Configuration</span>
</summary>
<div className="space-y-6 pl-4 border-l border-border/50 mb-6">
<div className="flex items-center justify-between p-4 rounded-xl bg-muted/30 border border-border/50">
<div className="space-y-0.5">
<p className="text-sm font-bold">Local Docker Wrapper</p>
<p className="text-[10px] uppercase font-black tracking-widest text-muted-foreground">Internal API Bridge</p>
</div>
<button
onClick={() => setUseMetaFreeWrapper(!useMetaFreeWrapper)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors outline-none",
useMetaFreeWrapper ? "bg-primary" : "bg-border"
)}
>
<span className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
useMetaFreeWrapper ? "translate-x-6" : "translate-x-1"
)} />
</button>
</div>
{useMetaFreeWrapper && (
<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">
{useMetaFreeWrapper && (
<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">Meta.ai Cookies</label>
<button
onClick={async () => {
try {
const text = await navigator.clipboard.readText();
setMetaCookies(text);
} catch (err) {
console.error('Clipboard error', err);
alert('Clipboard permission denied.');
}
}}
className="text-[10px] font-bold text-primary hover:underline"
>
Paste
</button>
</div>
<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"
<label className="text-sm font-medium text-white/70">Free Wrapper URL</label>
<input
type="text"
value={metaFreeWrapperUrl}
onChange={(e) => setMetaFreeWrapperUrl(e.target.value)}
placeholder="http://localhost:8000"
className="w-full p-2 rounded-lg bg-secondary/30 border border-border/50 focus:ring-1 focus:ring-primary/50 outline-none font-mono text-xs text-white/60"
/>
</div>
<div 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>
)}
</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>
</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>
);

View file

@ -5,9 +5,6 @@ import { useStore, ReferenceCategory } from '@/lib/store';
import { Clock, Upload, Trash2, CheckCircle, X, Film, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
// FastAPI backend URL
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export function UploadHistory() {
const {
history, setHistory,
@ -30,7 +27,6 @@ export function UploadHistory() {
};
// Check if an item is currently selected as a reference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSelected = (item: any) => {
if (!selectionMode) return false;
const categoryRefs = references[selectionMode as ReferenceCategory] || [];
@ -39,7 +35,6 @@ export function UploadHistory() {
};
// Toggle selection - add or remove from references
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleToggleSelect = (item: any) => {
if (!selectionMode) return;
@ -118,7 +113,7 @@ export function UploadHistory() {
// Optimistic UI update could happen here
const res = await fetch(`${API_BASE}/references/upload`, {
const res = await fetch('/api/references/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -207,65 +202,63 @@ export function UploadHistory() {
)}
{!selectionMode && (
<div className="flex flex-col items-center text-center gap-6 mb-12">
<div className="flex flex-col items-center gap-3">
<div className="p-4 bg-secondary rounded-2xl text-primary shadow-sm">
<Clock className="h-8 w-8" />
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<div className="p-3 bg-secondary rounded-xl text-primary">
<Clock className="h-6 w-6" />
</div>
<div>
<h2 className="text-3xl font-black tracking-tight">Uploads</h2>
<p className="text-muted-foreground text-sm font-medium">Your reference collection.</p>
<h2 className="text-2xl font-bold">Uploads</h2>
<p className="text-muted-foreground">Your reference collection.</p>
</div>
</div>
{history.length > 0 && (
<button
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" />
<span>Clear All History</span>
<Trash2 className="h-4 w-4" />
<span>Clear All</span>
</button>
)}
</div>
)}
{/* Filter Tabs */}
<div className="flex justify-center mb-10 overflow-hidden">
<div className="flex flex-wrap items-center justify-center gap-1.5 bg-secondary/30 p-1.5 rounded-3xl border border-border/50 shadow-soft max-w-full">
{(['all', 'subject', 'scene', 'style', 'videos'] as const).map(cat => (
<button
key={cat}
onClick={() => setFilter(cat)}
className={cn(
"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
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
{cat}
</button>
))}
</div>
<div className="flex items-center gap-2 mb-6 bg-secondary/30 p-1 rounded-xl w-fit">
{(['all', 'subject', 'scene', 'style', 'videos'] as const).map(cat => (
<button
key={cat}
onClick={() => setFilter(cat)}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
filter === cat
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
{cat}
</button>
))}
</div>
{/* Content Area */}
{filter === 'videos' ? (
// Video Grid
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="p-6 bg-secondary/30 rounded-full mb-6">
<Film className="h-10 w-10 opacity-40 text-primary" />
<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-4 bg-secondary/50 rounded-full mb-4">
<Film className="h-8 w-8 opacity-50" />
</div>
<h3 className="text-xl font-black text-foreground mb-2">No videos yet</h3>
<p className="text-sm text-center font-medium opacity-60">
Generate videos from your gallery images using the primary generator.
<h3 className="text-lg font-medium mb-1">No videos yet</h3>
<p className="text-sm text-center max-w-xs">
Generate videos from your gallery images.
</p>
</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) => (
<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
src={vid.url}
poster={`data:image/png;base64,${vid.thumbnail}`}
@ -273,16 +266,16 @@ export function UploadHistory() {
controls
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
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" />
</button>
</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">
<p className="text-white text-[10px] font-medium line-clamp-2 uppercase tracking-tight">{vid.prompt}</p>
<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">{vid.prompt}</p>
</div>
</div>
))}
@ -291,13 +284,13 @@ export function UploadHistory() {
) : (
// Image/Uploads Grid (Existing Logic)
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="p-6 bg-secondary/30 rounded-full mb-6">
<Upload className="h-10 w-10 opacity-40 text-primary" />
<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-4 bg-secondary/50 rounded-full mb-4">
<Clock className="h-8 w-8 opacity-50" />
</div>
<h3 className="text-xl font-black text-foreground mb-2">No uploads yet</h3>
<p className="text-sm text-center font-medium opacity-60">
Drag and drop images anywhere or use the plus buttons in the creator.
<h3 className="text-lg font-medium mb-1">No uploads yet</h3>
<p className="text-sm text-center max-w-xs">
Drag and drop images here to upload.
</p>
</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,9 +4,13 @@ services:
container_name: kv-pix
restart: unless-stopped
ports:
- "3000:3000" # Next.js frontend
- "8000:8000" # FastAPI backend
- "8558:3000"
environment:
- 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 { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
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 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 {
async crawl(): Promise<Prompt[]> {

View file

@ -1,7 +1,7 @@
import fs from 'fs/promises';
import path from 'path';
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');
@ -21,15 +21,17 @@ export async function syncPromptsService(): Promise<{ success: boolean, count: n
const jimmyCrawler = new JimmyLvCrawler();
const youMindCrawler = new YouMindCrawler();
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(),
youMindCrawler.crawl(),
zeroLuCrawler.crawl()
zeroLuCrawler.crawl(),
habuCrawler.crawl()
]);
const crawledPrompts = [...jimmyPrompts, ...youMindPrompts, ...zeroLuPrompts];
console.log(`[SyncService] Total crawled ${crawledPrompts.length} prompts (Jimmy: ${jimmyPrompts.length}, YouMind: ${youMindPrompts.length}, ZeroLu: ${zeroLuPrompts.length}).`);
const crawledPrompts = [...jimmyPrompts, ...youMindPrompts, ...zeroLuPrompts, ...habuPrompts];
console.log(`[SyncService] Total crawled ${crawledPrompts.length} prompts (Jimmy: ${jimmyPrompts.length}, YouMind: ${youMindPrompts.length}, ZeroLu: ${zeroLuPrompts.length}, Habu: ${habuPrompts.length}).`);
// 2. Read existing
const cache = await getPrompts();

View file

@ -84,7 +84,7 @@ export class WhiskClient {
if (c.name && c.value) cookies[c.name] = c.value;
}
return cookies;
} catch { /* ignore */ }
} catch (e) { /* ignore */ }
}
// Try header string
@ -172,7 +172,7 @@ export class WhiskClient {
prompt: string,
aspectRatio: string = "1:1",
refs: { subject?: string | string[]; scene?: string | string[]; style?: string | string[] } = {},
_preciseMode: boolean = false
preciseMode: boolean = false
): Promise<GeneratedImage[]> {
const token = await this.getAccessToken();
@ -216,7 +216,7 @@ export class WhiskClient {
seed: seed,
prompt: prompt,
mediaCategory: "MEDIA_CATEGORY_BOARD"
} as any; // eslint-disable-line @typescript-eslint/no-explicit-any
} as any;
} else {
// Image-to-Image (Recipe) - uses runImageRecipe endpoint
// 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
recipeMediaInputs: recipeMediaInputs
// 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})`);
@ -535,7 +535,7 @@ export class WhiskClient {
// IN_PROGRESS, PENDING, PROCESSING, RUNNING - continue polling
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)
if (e.message?.includes('Video generation failed:') ||
e.message?.includes('NCII') ||

View file

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

10
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{
"name": "v2_temp",
"version": "0.1.0",
"version": "2.5.0",
"private": true,
"scripts": {
"dev": "next dev",
@ -33,4 +33,4 @@
"typescript": "^5",
"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