Compare commits

...

20 commits
v1.0.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
Khoa.vo
072c7adf89 chore: Remove unused grok and meta-chat API routes
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:50:03 +07:00
Khoa.vo
e8978bb086 fix: Replace BigInt literal with BigInt function for ES compatibility
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:40:49 +07:00
Khoa.vo
ad19603f7c fix: Remove GrokChat import and usage from page.tsx
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:39:29 +07:00
Khoa.vo
6e833b24a6 fix: Remove all remaining grok references, delete GrokChat.tsx
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:38:27 +07:00
Khoa.vo
5d4413ff51 fix: Update Settings type to remove grok and add meta wrapper props
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:36:46 +07:00
Khoa.vo
962ff4667c fix: Add explicit type for mergedCookies
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:34:47 +07:00
Khoa.vo
ccfa897ac9 fix: Remove final grok reference from modal styling
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:32:59 +07:00
Khoa.vo
d43d979e43 fix: Remove grok from provider badge styling
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:31:25 +07:00
Khoa.vo
21abb11766 fix: Add removeVideo to store imports
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:29:57 +07:00
Khoa.vo
2e203dad19 fix: Replace addGeneratedImage with addToGallery
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:28:08 +07:00
Khoa.vo
bf4a56e550 fix: Remove grok from provider type union
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:26:50 +07:00
Khoa.vo
2173eb1446 fix: Add provider to editSource type in Gallery.tsx
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-07 19:25:07 +07:00
Khoa.vo
e69c6ba64d chore: Remove Grok integration, simplify Settings UI
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Removed all Grok-related code, API routes, and services
- Removed crawl4ai service and meta-crawl client
- Simplified Settings to always show cookie inputs for Meta AI
- Hid advanced wrapper settings behind collapsible section
- Provider selection now only shows Whisk and Meta AI
- Fixed unused imports and type definitions
2026-01-07 19:21:51 +07:00
Khoa.vo
537b1b80e5 refactor: simplify gallery thumbnail UI
Some checks failed
CI / build (18.x) (push) Has been cancelled
CI / build (20.x) (push) Has been cancelled
- Remove all action buttons from thumbnail overlay
- Keep only provider tag and delete button (X)
- Keep prompt text visible on hover
- All actions (download, video, remix, etc.) now in lightbox
2026-01-06 14:58:14 +07:00
Khoa.vo
c2ee01b7b7 feat: enable Subject upload for Meta AI video generation
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Subject button now enabled for Meta AI (Scene/Style still disabled)
- Video button appears when Subject reference is uploaded
- Uses Subject image for image-to-video generation
- Added handleGenerateVideo() in PromptHero
- Tooltip: 'Upload image to animate into video'
2026-01-06 14:51:08 +07:00
Khoa.vo
7aaa4c8166 refactor: simplify Meta AI video workflow
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Remove Video button from PromptHero (generate images first, video from gallery)
- Add Meta AI image-to-video in Gallery component
- Redesign lightbox with split panel layout (image left, controls right)
- Add handleGenerateMetaVideo() for Meta AI video from gallery
- Fix reference buttons (all disabled for Meta AI)
- Lightbox now shows: Download, Generate Video, Remix/Edit, Copy Prompt, Delete
2026-01-06 14:33:44 +07:00
Khoa.vo
bae4c487da feat: add image-to-video support for Meta AI
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Add Litterbox temporary image hosting for image URLs
- Update backend to accept image_base64 parameter
- Update TypeScript client and API route
- Subject button now enabled for Meta AI (for image-to-video)
- Button changes from 'Video' to 'Animate' when subject is set
- Pink/purple gradient for image-to-video, blue/cyan for text-to-video
2026-01-06 14:11:26 +07:00
Khoa.vo
0f87b8ef99 feat: add Meta AI video generation
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
- Add /video/generate endpoint to crawl4ai Python service
- Add VideoGenerateRequest and VideoGenerateResponse models
- Add generateVideo method to MetaCrawlClient TypeScript client
- Add /api/meta/video Next.js API route
- Add 'Video' button in PromptHero UI (visible only for Meta AI provider)
- Blue/cyan gradient styling for Video button to differentiate from Generate
2026-01-06 13:52:31 +07:00
Khoa.vo
2a4bf8b58b feat: updates before deployment
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
2026-01-06 13:26:11 +07:00
2333 changed files with 55167 additions and 3743 deletions

View file

@ -7,3 +7,4 @@ README.md
.git
.env*
! .env.example
.venv

6
.gitignore vendored
View file

@ -40,3 +40,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# local env
.venv
error.log
__pycache__
*.log

View file

@ -62,10 +62,20 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ images });
} catch (error: any) {
console.error("Generate API Error:", error);
console.error("Generate API Error Details:", {
message: error.message,
stack: error.stack,
fullError: error
});
const msg = error.message || "";
const isAuthError = msg.includes("401") || msg.includes("403") ||
msg.includes("Auth") || msg.includes("auth") ||
msg.includes("cookies") || msg.includes("expired");
return NextResponse.json(
{ error: error.message || "Generation failed" },
{ status: 500 }
{ status: isAuthError ? 401 : 500 }
);
}
}

View file

@ -1,60 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { GrokClient } from '@/lib/providers/grok-client';
export async function POST(req: NextRequest) {
try {
const { prompt, apiKey, cookies, imageCount = 1 } = await req.json();
if (!prompt) {
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
}
if (!apiKey && !cookies) {
return NextResponse.json(
{ error: "Grok API key or cookies required. Configure in Settings." },
{ status: 401 }
);
}
console.log(`[Grok API Route] Generating ${imageCount} image(s) for: "${prompt.substring(0, 30)}..."`);
const client = new GrokClient({ apiKey, cookies });
const results = await client.generate(prompt, imageCount);
// Download images as base64 for storage
const images = await Promise.all(
results.map(async (img) => {
let base64 = img.data;
if (!base64 && img.url && !img.url.startsWith('data:')) {
try {
base64 = await client.downloadAsBase64(img.url);
} catch (e) {
console.warn("[Grok API Route] Failed to download image:", e);
}
}
return {
data: base64 || '',
url: img.url,
prompt: img.prompt,
model: img.model,
aspectRatio: '1:1' // Grok default
};
})
);
const validImages = images.filter(img => img.data || img.url);
if (validImages.length === 0) {
throw new Error("No valid images generated");
}
return NextResponse.json({ images: validImages });
} catch (error: any) {
console.error("[Grok API Route] Error:", error);
return NextResponse.json(
{ error: error.message || "Grok generation failed" },
{ status: 500 }
);
}
}

View file

@ -3,23 +3,39 @@ import { MetaAIClient } from '@/lib/providers/meta-client';
export async function POST(req: NextRequest) {
try {
const { prompt, cookies, imageCount = 4 } = await req.json();
const { prompt, cookies, imageCount = 4, aspectRatio = 'portrait', useMetaFreeWrapper, metaFreeWrapperUrl } = await req.json();
if (!prompt) {
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
}
if (!cookies) {
// Only check for cookies if NOT using free wrapper
if (!useMetaFreeWrapper && !cookies) {
return NextResponse.json(
{ error: "Meta AI cookies required. Configure in Settings." },
{ error: "Meta AI cookies required. Configure in Settings or use Free Wrapper." },
{ status: 401 }
);
}
console.log(`[Meta AI Route] Generating images for: "${prompt.substring(0, 30)}..."`);
console.log(`[Meta AI Route] Generating images for: "${prompt.substring(0, 30)}..." (${aspectRatio})`);
const client = new MetaAIClient({ cookies });
const results = await client.generate(prompt, imageCount);
// Diagnostic: Check how many cookies we received
try {
const parsed = typeof cookies === 'string' && cookies.trim().startsWith('[')
? JSON.parse(cookies)
: cookies;
const count = Array.isArray(parsed) ? parsed.length : (typeof cookies === 'string' ? cookies.split(';').length : 0);
console.log(`[Meta AI Route] Received ${count} cookies (Free Wrapper: ${useMetaFreeWrapper})`);
} catch {
console.log(`[Meta AI Route] Cookie format: ${typeof cookies}`);
}
const client = new MetaAIClient({
cookies: cookies || '',
useFreeWrapper: useMetaFreeWrapper,
freeWrapperUrl: metaFreeWrapperUrl
});
const results = await client.generate(prompt, imageCount, aspectRatio);
// Download images as base64 for storage
const images = await Promise.all(
@ -48,7 +64,7 @@ export async function POST(req: NextRequest) {
throw new Error("No valid images generated");
}
return NextResponse.json({ images: validImages });
return NextResponse.json({ success: true, images: validImages });
} catch (error: any) {
console.error("[Meta AI Route] Error:", error);

371
app/api/meta/video/route.ts Normal file
View file

@ -0,0 +1,371 @@
import { NextRequest, NextResponse } from 'next/server';
import { MetaAIClient } from '@/lib/providers/meta-client';
/**
* POST /api/meta/video
*
* Generate a video from a text prompt using Meta AI's Kadabra engine.
* Uses MetaAIClient for session initialization (which works for images).
*/
const META_AI_BASE = "https://www.meta.ai";
const GRAPHQL_ENDPOINT = `${META_AI_BASE}/api/graphql/`;
// Video generation doc IDs from metaai-api
const VIDEO_INITIATE_DOC_ID = "25290947477183545"; // useKadabraSendMessageMutation
const VIDEO_POLL_DOC_ID = "25290569913909283"; // KadabraPromptRootQuery
export async function POST(req: NextRequest) {
try {
const { prompt, cookies: clientCookies, aspectRatio = 'portrait' } = await req.json();
if (!prompt) {
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
}
if (!clientCookies) {
return NextResponse.json(
{ error: "Meta AI cookies not found. Please configure settings." },
{ status: 401 }
);
}
console.log(`[Meta Video API] Generating video for: "${prompt.substring(0, 50)}..." (${aspectRatio})`);
// Use MetaAIClient for session initialization (proven to work)
const client = new MetaAIClient({ cookies: clientCookies });
const session = await client.getSession();
const cookieString = client.getCookies();
console.log("[Meta Video] Using MetaAIClient session:", {
hasLsd: !!session.lsd,
hasDtsg: !!session.fb_dtsg,
hasAccessToken: !!session.accessToken
});
// Generate unique IDs for this request
const externalConversationId = crypto.randomUUID();
const offlineThreadingId = Date.now().toString() + Math.random().toString().substring(2, 8);
// Initiate video generation with aspect ratio
await initiateVideoGeneration(prompt, externalConversationId, offlineThreadingId, session, cookieString, aspectRatio);
// Poll for video completion
const videos = await pollForVideoResult(externalConversationId, session, cookieString);
if (videos.length === 0) {
throw new Error("No videos generated");
}
return NextResponse.json({
success: true,
videos: videos.map(v => ({ url: v.url, prompt: prompt })),
conversation_id: externalConversationId
});
} catch (error: unknown) {
const err = error as Error;
console.error("[Meta Video API] Error:", err.message);
const msg = err.message || "";
const isAuthError = msg.includes("401") || msg.includes("403") ||
msg.includes("auth") || msg.includes("cookies") || msg.includes("expired") || msg.includes("Login");
return NextResponse.json(
{ error: err.message || "Video generation failed" },
{ status: isAuthError ? 401 : 500 }
);
}
}
interface MetaSession {
lsd?: string;
fb_dtsg?: string;
accessToken?: string;
}
/**
* Normalize cookies from JSON array to string format
*/
function normalizeCookies(cookies: string): string {
if (!cookies) return '';
try {
const trimmed = cookies.trim();
if (trimmed.startsWith('[')) {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((c: any) => `${c.name}=${c.value}`).join('; ');
}
}
} catch (e) {
// Not JSON, assume it's already a string format
}
return cookies;
}
/**
* Initialize session - get access token and LSD from meta.ai page
*/
async function initSession(cookies: string, retryCount: number = 0): Promise<MetaSession> {
console.log("[Meta Video] Initializing session...");
console.log("[Meta Video] Cookie string length:", cookies.length);
// Add small delay to avoid rate limiting (especially after image generation)
if (retryCount > 0) {
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
const response = await fetch(META_AI_BASE, {
headers: {
"Cookie": cookies,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
});
const html = await response.text();
console.log("[Meta Video] HTML Preview:", html.substring(0, 200));
// Detect Facebook error page and retry
if (html.includes('<title>Error</title>') || html.includes('id="facebook"')) {
console.warn("[Meta Video] Received Facebook error page, retrying...");
if (retryCount < 3) {
return initSession(cookies, retryCount + 1);
}
throw new Error("Meta AI: Server temporarily unavailable. Please try again in a moment.");
}
const session: MetaSession = {};
// Extract LSD token - multiple patterns for different Meta AI versions
const lsdMatch = html.match(/"LSD",\[\],\{"token":"([^"]+)"/) ||
html.match(/"lsd":"([^"]+)"/) ||
html.match(/name="lsd" value="([^"]+)"/) ||
html.match(/"token":"([^"]+)".*?"name":"lsd"/);
if (lsdMatch) {
session.lsd = lsdMatch[1];
}
// Extract access token
const tokenMatch = html.match(/"accessToken":"([^"]+)"/) ||
html.match(/accessToken['"]\s*:\s*['"]([^'"]+)['"]/);
if (tokenMatch) {
session.accessToken = tokenMatch[1];
}
// Extract DTSG token - try multiple patterns
const dtsgMatch = html.match(/DTSGInitData",\[\],\{"token":"([^"]+)"/) ||
html.match(/"DTSGInitialData".*?"token":"([^"]+)"/) ||
html.match(/fb_dtsg['"]\s*:\s*\{[^}]*['"]token['"]\s*:\s*['"]([^'"]+)['"]/);
if (dtsgMatch) {
session.fb_dtsg = dtsgMatch[1];
}
// Also try to extract from cookies if not found in HTML
if (!session.lsd) {
const lsdCookie = cookies.match(/lsd=([^;]+)/);
if (lsdCookie) session.lsd = lsdCookie[1];
}
if (!session.fb_dtsg) {
const dtsgCookie = cookies.match(/fb_dtsg=([^;]+)/);
if (dtsgCookie) session.fb_dtsg = dtsgCookie[1];
}
if (html.includes('login_form') || html.includes('login_page')) {
throw new Error("Meta AI: Cookies expired. Please update in Settings.");
}
console.log("[Meta Video] Session tokens extracted:", {
hasLsd: !!session.lsd,
hasDtsg: !!session.fb_dtsg,
hasAccessToken: !!session.accessToken
});
return session;
}
/**
* Initiate video generation using Kadabra mutation
*/
async function initiateVideoGeneration(
prompt: string,
conversationId: string,
threadingId: string,
session: MetaSession,
cookies: string,
aspectRatio: string = 'portrait'
): Promise<void> {
console.log("[Meta Video] Initiating video generation...");
// Map aspect ratio to orientation
const orientationMap: Record<string, string> = {
'portrait': 'VERTICAL',
'landscape': 'HORIZONTAL',
'square': 'SQUARE'
};
const orientation = orientationMap[aspectRatio] || 'VERTICAL';
const variables = {
message: {
prompt_text: prompt,
external_conversation_id: conversationId,
offline_threading_id: threadingId,
imagineClientOptions: { orientation: orientation },
selectedAgentType: "PLANNER"
},
__relay_internal__pv__AbraArtifactsEnabledrelayprovider: true
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useKadabraSendMessageMutation",
variables: JSON.stringify(variables),
doc_id: VIDEO_INITIATE_DOC_ID,
...(session.lsd && { lsd: session.lsd }),
...(session.fb_dtsg && { fb_dtsg: session.fb_dtsg })
});
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookies,
"Origin": META_AI_BASE,
"Referer": `${META_AI_BASE}/`,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
...(session.accessToken && { "Authorization": `OAuth ${session.accessToken}` })
},
body: body.toString()
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Meta Video] Initiation failed:", response.status, errorText);
throw new Error(`Meta AI Error: ${response.status}`);
}
const data = await response.json();
console.log("[Meta Video] Initiation response:", JSON.stringify(data).substring(0, 200));
// Check for errors
if (data.errors) {
throw new Error(data.errors[0]?.message || "Video initiation failed");
}
}
/**
* Poll for video result using KadabraPromptRootQuery
*/
async function pollForVideoResult(
conversationId: string,
session: MetaSession,
cookies: string
): Promise<{ url: string }[]> {
const maxAttempts = 60; // 2 minutes max
const pollInterval = 2000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`[Meta Video] Polling attempt ${attempt + 1}/${maxAttempts}...`);
await new Promise(resolve => setTimeout(resolve, pollInterval));
const variables = {
external_conversation_id: conversationId
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "KadabraPromptRootQuery",
variables: JSON.stringify(variables),
doc_id: VIDEO_POLL_DOC_ID,
...(session.lsd && { lsd: session.lsd }),
...(session.fb_dtsg && { fb_dtsg: session.fb_dtsg })
});
try {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookies,
"Origin": META_AI_BASE,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
...(session.accessToken && { "Authorization": `OAuth ${session.accessToken}` })
},
body: body.toString()
});
const data = await response.json();
// Extract video URLs from response
const videos = extractVideosFromResponse(data);
if (videos.length > 0) {
console.log(`[Meta Video] Got ${videos.length} video(s)!`);
return videos;
}
// Check for error status
const status = data?.data?.kadabra_prompt?.status;
if (status === "FAILED" || status === "ERROR") {
throw new Error("Meta AI video generation failed");
}
} catch (e: any) {
console.error("[Meta Video] Poll error:", e.message);
if (attempt === maxAttempts - 1) throw e;
}
}
throw new Error("Meta AI: Video generation timed out");
}
/**
* Extract video URLs from GraphQL response
*/
function extractVideosFromResponse(response: any): { url: string }[] {
const videos: { url: string }[] = [];
try {
// Navigate through possible response structures
const prompt = response?.data?.kadabra_prompt;
const messages = prompt?.messages?.edges || [];
for (const edge of messages) {
const node = edge?.node;
const attachments = node?.attachments || [];
for (const attachment of attachments) {
// Check for video media
const media = attachment?.media;
if (media?.video_uri) {
videos.push({ url: media.video_uri });
}
if (media?.playable_url) {
videos.push({ url: media.playable_url });
}
if (media?.browser_native_sd_url) {
videos.push({ url: media.browser_native_sd_url });
}
}
// Check imagine_card for video results
const imagineCard = node?.imagine_card;
if (imagineCard?.session?.media_sets) {
for (const mediaSet of imagineCard.session.media_sets) {
for (const media of mediaSet?.imagine_media || []) {
if (media?.video_uri) {
videos.push({ url: media.video_uri });
}
}
}
}
}
} catch (e) {
console.error("[Meta Video] Error extracting videos:", e);
}
return videos;
}

View file

@ -7,6 +7,10 @@ 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({

View file

@ -10,6 +10,9 @@ import { Settings } from "@/components/Settings";
import { PromptLibrary } from "@/components/PromptLibrary";
import { UploadHistory } from "@/components/UploadHistory";
import { CookieExpiredDialog } from "@/components/CookieExpiredDialog";
export default function Home() {
const { currentView, setCurrentView, loadGallery } = useStore();
@ -48,6 +51,9 @@ export default function Home() {
</div>
</div>
</main>
<CookieExpiredDialog />
</div>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import React from 'react';
import { useStore } from '@/lib/store';
import { AlertTriangle, Settings, X, Cookie, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
export function CookieExpiredDialog() {
const {
showCookieExpired,
setShowCookieExpired,
setCurrentView,
settings
} = useStore();
if (!showCookieExpired) return null;
const providerName = settings.provider === 'meta' ? 'Meta AI' : 'Google Whisk';
const providerUrl = settings.provider === 'meta' ? 'https://www.meta.ai' :
'https://labs.google/fx/tools/whisk/project';
const handleFixIssues = () => {
setShowCookieExpired(false);
setCurrentView('settings');
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="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">
{/* 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-6 px-8 flex flex-col items-center text-center">
<button
onClick={() => setShowCookieExpired(false)}
className="absolute top-4 right-4 p-2 text-white/40 hover:text-white rounded-full hover:bg-white/5 transition-colors"
>
<X className="h-4 w-4" />
</button>
<div className="h-16 w-16 mb-6 rounded-full bg-amber-500/10 flex items-center justify-center ring-1 ring-amber-500/20 shadow-lg shadow-amber-900/20">
<Cookie className="h-8 w-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Cookies Expired</h2>
<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-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-4 w-4" />
Update Settings
</button>
<a
href={providerUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full py-3 px-4 bg-white/5 hover:bg-white/10 text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2 border border-white/5"
>
<ExternalLink className="h-4 w-4" />
Open {providerName}
</a>
</div>
</div>
</div>
</div>
);
}

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
interface EditPromptModalProps {
isOpen: boolean;
onClose: () => void;
image: { data: string; prompt: string } | null;
image: { data: string; prompt: string; provider?: string } | null;
onGenerate: (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => Promise<void>;
}
@ -18,12 +18,16 @@ export function EditPromptModal({ isOpen, onClose, image, onGenerate }: EditProm
const [keepScene, setKeepScene] = React.useState(true);
const [keepStyle, setKeepStyle] = React.useState(true);
const isMeta = image?.provider === 'meta';
React.useEffect(() => {
if (isOpen && image) {
setPrompt(image.prompt);
}
}, [isOpen, image]);
// ... (lines 27-130 remain unchanged, so we skip them in replace tool if possible,
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
@ -122,43 +126,47 @@ export function EditPromptModal({ isOpen, onClose, image, onGenerate }: EditProm
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full h-24 p-3 rounded-xl bg-white/5 border border-white/10 resize-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500/50 outline-none text-sm text-white placeholder:text-white/30 transition-all"
placeholder="Describe your remix... The selected consistency options will be preserved..."
placeholder={isMeta ? "Modify your prompt to generate a new variation..." : "Describe your remix... The selected consistency options will be preserved..."}
autoFocus
/>
</div>
</div>
{/* Consistency Toggles */}
<div className="mt-4">
<label className="text-xs font-medium text-white/50 mb-2 block">Keep Consistent:</label>
<div className="flex flex-wrap gap-2">
<ConsistencyToggle
label="Subject"
checked={keepSubject}
onChange={setKeepSubject}
color="text-blue-400"
/>
<ConsistencyToggle
label="Scene"
checked={keepScene}
onChange={setKeepScene}
color="text-green-400"
/>
<ConsistencyToggle
label="Style"
checked={keepStyle}
onChange={setKeepStyle}
color="text-purple-400"
/>
{!isMeta && (
<div className="mt-4">
<label className="text-xs font-medium text-white/50 mb-2 block">Keep Consistent:</label>
<div className="flex flex-wrap gap-2">
<ConsistencyToggle
label="Subject"
checked={keepSubject}
onChange={setKeepSubject}
color="text-blue-400"
/>
<ConsistencyToggle
label="Scene"
checked={keepScene}
onChange={setKeepScene}
color="text-green-400"
/>
<ConsistencyToggle
label="Style"
checked={keepStyle}
onChange={setKeepStyle}
color="text-purple-400"
/>
</div>
</div>
</div>
)}
{/* Info about consistency */}
<div className="mt-4 p-3 bg-white/5 rounded-xl border border-white/10">
<p className="text-xs text-white/50">
<span className="text-amber-400">💡</span> Locked elements will be used as references to maintain visual consistency across generations.
</p>
</div>
{!isMeta && (
<div className="mt-4 p-3 bg-white/5 rounded-xl border border-white/10">
<p className="text-xs text-white/50">
<span className="text-amber-400">💡</span> Locked elements will be used as references to maintain visual consistency across generations.
</p>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 mt-6">

View file

@ -2,21 +2,56 @@
import React from 'react';
import { useStore } from '@/lib/store';
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from 'framer-motion';
import { Download, Maximize2, Sparkles, Trash2, X, ChevronLeft, ChevronRight, Copy, Film, Wand2 } from 'lucide-react';
import { VideoPromptModal } from './VideoPromptModal';
import { EditPromptModal } from './EditPromptModal';
// Helper function to get proper image src (handles URLs vs base64)
const getImageSrc = (data: string): string => {
if (!data) return '';
const cleanData = data.trim();
// If it's already a URL, use it directly
if (cleanData.indexOf('http') === 0 || cleanData.indexOf('data:') === 0) {
return cleanData;
}
// Otherwise, treat as base64 (don't warn - base64 often contains 'http' as random characters)
return `data:image/png;base64,${cleanData}`;
};
export function Gallery() {
const { gallery, clearGallery, removeFromGallery, setPrompt, addVideo, addToGallery, settings, videos, removeVideo } = useStore();
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const {
gallery, loadGallery, addToGallery, removeFromGallery, clearGallery,
isGenerating,
settings,
videos, addVideo, removeVideo,
setPrompt
} = useStore();
const [videoModalOpen, setVideoModalOpen] = React.useState(false);
const [videoSource, setVideoSource] = React.useState<{ data: string, prompt: string } | null>(null);
const [videoSource, setVideoSource] = React.useState<{ data: string, prompt: string, provider?: string } | null>(null);
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [editSource, setEditSource] = React.useState<{ data: string, prompt: string } | null>(null);
const [editSource, setEditSource] = React.useState<{ data: string, prompt: string, provider?: 'whisk' | 'meta' } | null>(null);
const [editPromptValue, setEditPromptValue] = React.useState('');
const [videoPromptValue, setVideoPromptValue] = React.useState('');
const [useSourceImage, setUseSourceImage] = React.useState(true);
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const openVideoModal = (img: { data: string, prompt: string }) => {
React.useEffect(() => {
if (selectedIndex !== null && gallery[selectedIndex]) {
setEditSource(gallery[selectedIndex]);
setEditPromptValue(gallery[selectedIndex].prompt || '');
setVideoPromptValue('');
setUseSourceImage(true);
}
}, [selectedIndex, gallery]);
React.useEffect(() => {
loadGallery();
}, []); // Only load on mount
const openVideoModal = (img: { data: string, prompt: string, provider?: string }) => {
setVideoSource(img);
setVideoModalOpen(true);
};
@ -26,59 +61,216 @@ export function Gallery() {
setEditModalOpen(true);
};
const handleGenerateVideo = async (prompt: string) => {
if (!videoSource) return;
const [isGeneratingMetaVideo, setIsGeneratingMetaVideo] = React.useState(false); // Kept for UI state compatibility
const [isGeneratingWhiskVideo, setIsGeneratingWhiskVideo] = React.useState(false);
// Handle Meta AI video generation (text-to-video via Kadabra)
const handleGenerateMetaVideo = async (img: { data: string; prompt: string }, customPrompt?: string) => {
if (!settings.metaCookies && !settings.facebookCookies) {
alert("Please set your Meta AI (or Facebook) Cookies in Settings first!");
return;
}
setIsGeneratingMetaVideo(true);
try {
console.log("[Gallery] Starting Meta AI video generation...");
// Create a descriptive prompt that includes the original image context + animation
const originalPrompt = img.prompt || "";
const animationDescription = customPrompt || "natural movement";
// Combine original image description with animation instruction
const promptText = originalPrompt
? `Create a video of: ${originalPrompt}. Animation: ${animationDescription}`
: `Create an animated video with: ${animationDescription}`;
console.log("[Gallery] Meta video prompt:", promptText);
// Merge cookies safely
let mergedCookies = settings.metaCookies;
try {
const safeParse = (str: string) => {
if (!str || str === "undefined" || str === "null") return [];
try { return JSON.parse(str); } catch { return []; }
};
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) {
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
}
} catch (e) { console.error("Cookie merge failed", e); }
const res = await fetch('/api/meta/video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: promptText,
cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies)
})
});
const data = await res.json();
console.log("[Gallery] Meta video response:", data);
if (data.success && data.videos?.length > 0) {
for (const video of data.videos) {
addVideo({
id: crypto.randomUUID(),
url: video.url,
prompt: promptText,
thumbnail: img.data,
createdAt: Date.now()
});
}
alert('🎬 Video generation complete! Scroll up to see your video.');
setVideoModalOpen(false);
} else {
throw new Error(data.error || 'No videos generated');
}
} 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')) {
errorMessage = '🔐 Your Meta AI cookies have expired. Please go to Settings and update them.';
}
alert(errorMessage);
} finally {
setIsGeneratingMetaVideo(false);
}
};
const handleGenerateVideo = async (prompt: string, sourceOverride?: { data: string; prompt: string; provider?: string; aspectRatio?: string }) => {
const activeSource = sourceOverride || videoSource;
if (!activeSource) return;
// Route to Meta AI video for meta provider
if (activeSource.provider === 'meta') {
await handleGenerateMetaVideo(activeSource, prompt);
return;
}
if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!");
throw new Error("Missing Whisk cookies");
}
const res = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
imageBase64: videoSource.data,
// imageGenerationId: (videoSource as any).id, // REMOVE: "id" is a local DB ID (e.g. 1), not a Whisk Media ID.
cookies: settings.whiskCookies
})
});
setIsGeneratingWhiskVideo(true);
const data = await res.json();
console.log("[Gallery] Video API response:", data);
if (data.success) {
console.log("[Gallery] Adding video to store:", { id: data.id, url: data.url?.substring(0, 50) });
addVideo({
id: data.id,
url: data.url,
prompt: prompt,
thumbnail: videoSource.data, // Use source image as thumb
createdAt: Date.now()
try {
const res = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
imageBase64: activeSource.data,
cookies: settings.whiskCookies
})
});
// Success notification
setTimeout(() => {
const data = await res.json();
console.log("[Gallery] Video API response:", data);
if (data.success) {
console.log("[Gallery] Adding video to store:", { id: data.id, url: data.url?.substring(0, 50) });
addVideo({
id: data.id,
url: data.url,
prompt: prompt,
thumbnail: activeSource.data,
createdAt: Date.now()
});
alert('🎬 Video generation complete!\n\nYour video has been saved. Go to the "Uploads" page and select the "Videos" tab to view it.');
}, 100);
} else {
console.error(data.error);
// Show user-friendly error messages for Google safety policies
let errorMessage = data.error;
if (data.error?.includes('NCII')) {
errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII (Non-Consensual Intimate Imagery) protection. Please try with a different source image.';
} else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) {
errorMessage = '🚫 Content Policy: Video blocked because the image contains a recognizable person. Try using a different image.';
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters. Try a different source image.';
} else {
console.error(data.error);
let errorMessage = data.error;
if (data.error?.includes('NCII')) {
errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII protection. Please try with a different source image.';
} else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) {
errorMessage = '🚫 Content Policy: Video blocked because the image contains a recognizable person.';
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters.';
} else if (data.error?.includes('401') || data.error?.includes('UNAUTHENTICATED')) {
errorMessage = '🔐 Authentication Error: Your Whisk cookies have expired. Please update in Settings.';
} else if (data.error?.includes('429') || data.error?.includes('RESOURCE_EXHAUSTED')) {
errorMessage = '⏱️ Rate Limit: Too many requests. Please wait a few minutes and try again.';
}
alert(errorMessage);
throw new Error(data.error);
}
alert(errorMessage);
throw new Error(data.error);
} finally {
setIsGeneratingWhiskVideo(false);
}
};
const handleRemix = async (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => {
if (!editSource) return;
// Meta AI Remix Flow (Prompt Edit Only)
if (editSource.provider === 'meta') {
if (!settings.metaCookies && !settings.facebookCookies) {
alert("Please set your Meta AI (or Facebook) Cookies in Settings first!");
return;
}
try {
// Merge cookies safely
let mergedCookies = settings.metaCookies;
try {
const safeParse = (str: string) => {
if (!str || str === "undefined" || str === "null") return [];
try { return JSON.parse(str); } catch { return []; }
};
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) {
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
}
} catch (e) { console.error("Cookie merge failed", e); }
const res = await fetch('/api/meta/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies),
imageCount: 4
})
});
const data = await res.json();
if (data.error) throw new Error(data.error);
if (data.success && data.images?.length > 0) {
// Add new images to gallery
const newImages = data.images.map((img: any) => ({
id: crypto.randomUUID(),
data: img.data, // Base64
prompt: prompt,
createdAt: Date.now(),
width: 1024,
height: 1024,
aspectRatio: settings.aspectRatio,
provider: 'meta'
}));
// Add to store
newImages.forEach(addToGallery);
alert('✨ Remix complete! New images added to gallery.');
setEditModalOpen(false);
} else {
throw new Error('No images generated');
}
} catch (e: any) {
console.error("Meta Remix failed", e);
alert("Remix failed: " + e.message);
}
return;
}
// Whisk Remix Flow (Reference Injection)
if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!");
throw new Error("Missing Whisk cookies");
@ -154,9 +346,41 @@ export function Gallery() {
return null; // Or return generic empty state if controlled by parent, but parent checks length usually
}
const handleClearAll = () => {
if (window.confirm("Delete all " + gallery.length + " images?")) {
const handleClearAll = async () => {
const count = gallery.length;
if (!window.confirm(`Delete all ${count} images? This will reset the gallery database.`)) return;
try {
console.log("[Gallery] Hard clearing...");
// 1. Clear Zustand Store visual state immediate
clearGallery();
// 2. Nuclear Option: Delete the entire database file
console.log("[Gallery] Deleting IndexedDB...");
const req = window.indexedDB.deleteDatabase('kv-pix-db');
req.onsuccess = () => {
console.log("✅ DB Deleted successfully");
// Clear localStorage persistence too just in case
localStorage.removeItem('kv-pix-storage');
window.location.reload();
};
req.onerror = (e) => {
console.error("❌ Failed to delete DB", e);
alert("Failed to delete database. Browser might be blocking it.");
window.location.reload();
};
req.onblocked = () => {
console.warn("⚠️ DB Delete blocked - reloading to free locks");
window.location.reload();
};
} catch (e) {
console.error("[Gallery] Delete error:", e);
alert("❌ Failed to delete: " + String(e));
}
};
@ -219,6 +443,19 @@ export function Gallery() {
{/* Gallery Grid */}
<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-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>
))}
</>
)}
<AnimatePresence mode='popLayout'>
{gallery.map((img, i) => (
<motion.div
@ -231,13 +468,24 @@ export function Gallery() {
className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm"
>
<img
src={"data:image/png;base64," + img.data}
src={getImageSrc(img.data)}
alt={img.prompt}
className="w-full h-auto object-cover transition-transform group-hover:scale-105 cursor-pointer"
onClick={() => setSelectedIndex(i)}
loading="lazy"
/>
{/* 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>
)}
{/* Delete button - Top right */}
<button
onClick={(e) => { e.stopPropagation(); if (img.id) removeFromGallery(img.id); }}
@ -248,162 +496,235 @@ export function Gallery() {
<X className="h-4 w-4" />
</button>
{/* Overlay */}
{/* 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 mb-2">{img.prompt}</p>
<div className="flex gap-2 justify-end pointer-events-auto">
<button
onClick={(e) => {
e.stopPropagation();
setPrompt(img.prompt);
navigator.clipboard.writeText(img.prompt);
// Optional: Toast feedback could go here
}}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Use Prompt"
>
<Copy className="h-4 w-4" />
</button>
<a
href={"data:image/png;base64," + img.data}
download={"generated-" + i + "-" + Date.now() + ".png"}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Download"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-4 w-4" />
</a>
<button
onClick={(e) => {
e.stopPropagation();
openEditModal(img);
}}
className="p-2 bg-gradient-to-br from-amber-500/80 to-purple-600/80 hover:from-amber-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-purple-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Remix this image"
>
<Wand2 className="h-4 w-4" />
</button>
{/* Video button - only for 16:9 images */}
{img.aspectRatio === '16:9' ? (
<button
onClick={(e) => {
e.stopPropagation();
openVideoModal(img);
}}
className="p-2 bg-gradient-to-br from-blue-500/80 to-purple-600/80 hover:from-blue-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-blue-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Generate Video"
>
<Film className="h-4 w-4" />
</button>
) : (
<button
disabled
className="p-2 bg-gray-500/30 rounded-full text-white/30 cursor-not-allowed border border-white/5"
title="Video generation requires 16:9 images"
>
<Film className="h-4 w-4" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setSelectedIndex(i); }}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Maximize"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
<p className="text-white text-xs line-clamp-2">{img.prompt}</p>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Lightbox Modal */}
{/* Lightbox Modal - Split Panel Design */}
<AnimatePresence>
{selectedIndex !== null && selectedImage && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4 md:p-8"
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)}
>
{/* Close Button */}
<button
className="absolute top-4 right-4 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
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-6 w-6" />
<X className="h-5 w-5" />
</button>
{/* Navigation Buttons */}
{selectedIndex > 0 && (
<button
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
>
<ChevronLeft className="h-8 w-8" />
<ChevronLeft className="h-6 w-6 md:h-8 md:w-8" />
</button>
)}
{selectedIndex < gallery.length - 1 && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
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-8 w-8" />
<ChevronRight className="h-6 w-6 md:h-8 md:w-8" />
</button>
)}
{/* Image Container */}
{/* Split Panel Container */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative max-w-7xl max-h-full flex flex-col items-center"
exit={{ scale: 0.95, opacity: 0 }}
className="relative w-full max-w-6xl max-h-[90vh] flex flex-col md:flex-row gap-4 md:gap-6 overflow-hidden"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{/* Left: Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<img
src={getImageSrc(selectedImage.data)}
alt={selectedImage.prompt}
className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
/>
</div>
<img
src={"data:image/png;base64," + selectedImage.data}
alt={selectedImage.prompt}
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
/>
{/* 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>
<div className="mt-4 flex flex-col items-center gap-2 max-w-2xl text-center">
<p className="text-white/90 text-sm md:text-base font-medium line-clamp-2">
{selectedImage.prompt}
</p>
<div className="flex gap-3">
<a
href={"data:image/png;base64," + selectedImage.data}
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full font-medium transition-colors"
>
<Download className="h-4 w-4" />
Download Current
</a>
<button
onClick={() => {
if (selectedImage) openVideoModal(selectedImage);
if (selectedImage.id) {
removeFromGallery(selectedImage.id);
setSelectedIndex(null);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full font-medium transition-colors"
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"
>
<Film className="h-4 w-4" />
Generate Video
</button>
<button
onClick={() => {
setPrompt(selectedImage.prompt);
navigator.clipboard.writeText(selectedImage.prompt);
setSelectedIndex(null); // Close lightbox? Or keep open? User said "reuse", likely wants to edit.
// Let's close it so they can see the input updating.
}}
className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full font-medium transition-colors"
>
<Copy className="h-4 w-4" />
Use Prompt
<Trash2 className="h-3.5 w-3.5" />
<span>Delete Image</span>
</button>
</div>
{/* 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

@ -12,25 +12,73 @@ export function Navbar() {
const navItems = [
{ id: 'gallery', label: 'Create', icon: Sparkles },
{ id: 'library', label: 'Prompt Library', icon: LayoutGrid },
{ id: 'history', label: 'Uploads', icon: Clock }, // CORRECTED: id should match store ViewType 'history' not 'uploads'
{ id: 'history', label: 'Uploads', icon: Clock },
];
return (
<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="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-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">
<Sparkles className="h-6 w-6" />
<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">
<Sparkles className="h-6 w-6" />
</div>
<span className="text-xl font-bold text-foreground tracking-tight">kv-pix</span>
</div>
<span className="text-xl font-bold text-foreground tracking-tight">kv-pix</span>
</div>
{/* Center Navigation */}
<div className="hidden md:flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/50">
{/* Center Navigation (Desktop) */}
<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}
onClick={() => {
setCurrentView(item.id as any);
if (item.id === 'history') setSelectionMode(null);
}}
className={cn(
"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-secondary"
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
))}
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentView('settings')}
className={cn(
"p-2 transition-colors",
currentView === 'settings'
? "text-primary bg-primary/10 rounded-full"
: "text-muted-foreground hover:text-primary"
)}
>
<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-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-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}
@ -39,40 +87,41 @@ export function Navbar() {
if (item.id === 'history') setSelectionMode(null);
}}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all",
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
currentView === item.id
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
? "text-primary"
: "text-white/40 hover:text-white/80"
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
<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>
))}
</div>
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Settings Item for Mobile */}
<button
onClick={() => setCurrentView('settings')}
className={cn(
"p-2 transition-colors",
"flex flex-col items-center justify-center gap-1 p-2 rounded-xl transition-all w-16",
currentView === 'settings'
? "text-primary bg-primary/10 rounded-full"
: "text-muted-foreground hover:text-primary"
? "text-primary"
: "text-white/40 hover:text-white/80"
)}
>
<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-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 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-sm font-medium hidden sm:block">Khoa Vo</span>
<span className="text-[10px] font-medium">Settings</span>
</button>
</div>
</div>
</div>
</>
);
}

View file

@ -3,7 +3,7 @@
import React, { useRef, useState, useEffect } from "react";
import { useStore, ReferenceCategory } from "@/lib/store";
import { cn } from "@/lib/utils";
import { Sparkles, Image as ImageIcon, X, Hash, AlertTriangle, Upload, Zap, Brain } from "lucide-react";
import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Brain, Settings, Settings2 } from "lucide-react";
const IMAGE_COUNTS = [1, 2, 4];
@ -13,14 +13,47 @@ export function PromptHero() {
settings, setSettings,
references, setReference, addReference, removeReference, clearReferences,
setSelectionMode, setCurrentView,
history, setHistory
history, setHistory,
setIsGenerating, // Get global setter
setShowCookieExpired
} = useStore();
const [isGenerating, setIsGenerating] = useState(false);
const [isGenerating, setLocalIsGenerating] = useState(false);
const { addVideo } = useStore();
const [uploadingRefs, setUploadingRefs] = useState<Record<string, boolean>>({});
const [errorNotification, setErrorNotification] = useState<{ message: string; type: 'error' | 'warning' } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// CLEANUP: Remove corrupted localStorage keys that crash browser extensions
useEffect(() => {
try {
if (typeof window !== 'undefined') {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
const val = localStorage.getItem(key);
// Clean up "undefined" string values which cause JSON.parse errors in extensions
if (val === "undefined" || val === "null") {
console.warn(`[Cleanup] Removing corrupted localStorage key: ${key}`);
localStorage.removeItem(key);
}
}
}
}
} catch (e) {
console.error("Storage cleanup failed", e);
}
}, []);
// Auto-enable Precise mode when references are added
useEffect(() => {
const hasReferences = Object.values(references).some(refs => refs && refs.length > 0);
if (hasReferences && !settings.preciseMode) {
setSettings({ preciseMode: true });
}
}, [references, settings.preciseMode, setSettings]);
// File input refs for each reference category
const fileInputRefs = {
subject: useRef<HTMLInputElement>(null),
@ -55,33 +88,53 @@ export function PromptHero() {
}
setIsGenerating(true);
setLocalIsGenerating(true); // Keep local state for button UI
try {
// Route to the selected provider
const provider = settings.provider || 'whisk';
let res: Response;
if (provider === 'grok') {
// Grok API
res = await fetch('/api/grok/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: finalPrompt,
apiKey: settings.grokApiKey,
cookies: settings.grokCookies,
imageCount: settings.imageCount
})
});
} else if (provider === 'meta') {
// Meta AI
if (provider === 'meta') {
// Image Generation Path (Meta AI)
// Video is now handled by handleGenerateVideo
// Prepend aspect ratio for better adherence
let metaPrompt = finalPrompt;
if (settings.aspectRatio === '16:9') {
metaPrompt = "wide 16:9 landscape image of " + finalPrompt;
} else if (settings.aspectRatio === '9:16') {
metaPrompt = "tall 9:16 portrait image of " + finalPrompt;
}
// Merge cookies safely
let mergedCookies: string | any[] = settings.metaCookies;
try {
const safeParse = (str: string) => {
if (!str || str === "undefined" || str === "null") return [];
try { return JSON.parse(str); } catch { return []; }
};
const m = safeParse(settings.metaCookies);
const f = safeParse(settings.facebookCookies);
if (Array.isArray(m) || Array.isArray(f)) {
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])];
}
} catch (e) { console.error("Cookie merge failed", e); }
// Meta AI always generates 4 images, hardcode this
// Extract subject reference if available (for Image-to-Image)
const subjectRef = references.subject?.[0];
const imageUrl = subjectRef ? subjectRef.thumbnail : undefined; // Use full data URI from thumbnail property
res = await fetch('/api/meta/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: finalPrompt,
cookies: settings.metaCookies,
imageCount: settings.imageCount
prompt: metaPrompt,
cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies),
imageCount: 4, // Meta AI always returns 4 images
useMetaFreeWrapper: settings.useMetaFreeWrapper,
metaFreeWrapperUrl: settings.metaFreeWrapperUrl
})
});
} else {
@ -121,10 +174,11 @@ export function PromptHero() {
// Add images one by one with createdAt
for (const img of data.images) {
await addToGallery({
data: img.data,
prompt: img.prompt,
data: img.data || img.url, // Use URL as fallback (Meta AI returns URLs)
prompt: finalPrompt, // Use original user prompt to avoid showing engineered prompts
aspectRatio: img.aspectRatio || settings.aspectRatio,
createdAt: Date.now()
createdAt: Date.now(),
provider: provider as 'whisk' | 'meta'
});
}
}
@ -140,6 +194,12 @@ export function PromptHero() {
message: '🚫 Content Policy: The reference image contains a recognizable person. Google blocks generating images of real/famous people. Try using a different reference image without identifiable faces.',
type: 'warning'
});
} else if (errorMessage.includes("Oops! I can't generate that image") ||
errorMessage.includes("Can I help you imagine something else")) {
setErrorNotification({
message: '🛡️ Meta AI Safety: The prompt was rejected by Meta AI safety filters. Please try a different prompt.',
type: 'warning'
});
} else if (errorMessage.includes('Safety Filter') ||
errorMessage.includes('SAFETY_FILTER') ||
errorMessage.includes('content_policy')) {
@ -168,9 +228,15 @@ export function PromptHero() {
});
} else if (errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('cookies not found')) {
errorMessage.includes('cookies not found') ||
errorMessage.includes('Auth failed')) {
// Trigger the new popup
setShowCookieExpired(true);
// Also show a simplified toast as backup
setErrorNotification({
message: '🔐 Authentication Error: Your Whisk cookies may have expired. Please update them in Settings.',
message: '🔐 Authentication Error: Cookies Refreshed Required',
type: 'error'
});
} else {
@ -183,9 +249,12 @@ export function PromptHero() {
setTimeout(() => setErrorNotification(null), 8000);
} finally {
setIsGenerating(false);
setLocalIsGenerating(false);
}
};
// Note: Meta AI Video generation was removed - use Whisk for video generation from the gallery lightbox
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
@ -227,49 +296,70 @@ export function PromptHero() {
};
const uploadReference = async (file: File, category: ReferenceCategory) => {
if (!settings.whiskCookies) {
// Enforce Whisk cookies ONLY if using Whisk provider
if ((!settings.provider || settings.provider === 'whisk') && !settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!");
return;
}
setUploadingRefs(prev => ({ ...prev, [category]: true }));
try {
const reader = new FileReader();
reader.onload = async (e) => {
const base64 = e.target?.result as string;
if (!base64) return;
if (!base64) {
setUploadingRefs(prev => ({ ...prev, [category]: false }));
return;
}
const res = await fetch('/api/references/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageBase64: base64,
mimeType: file.type,
category: category,
cookies: settings.whiskCookies
})
});
let refId = '';
const data = await res.json();
if (data.id) {
// If Whisk, upload to backend to get ID
if (!settings.provider || settings.provider === 'whisk') {
try {
const res = await fetch('/api/references/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageBase64: base64,
mimeType: file.type,
category: category,
cookies: settings.whiskCookies
})
});
const data = await res.json();
if (data.id) {
refId = data.id;
} else {
console.error("Upload failed details:", JSON.stringify(data));
alert(`Upload failed: ${data.error}\n\nDetails: ${JSON.stringify(data) || 'Check console'}`);
}
} catch (err) {
console.error("API Upload Error", err);
alert("API Upload failed");
}
} else {
// For Meta/Grok, just use local generated ID
refId = 'loc-' + Date.now() + Math.random().toString(36).substr(2, 5);
}
if (refId) {
// Add to array (supports multiple refs per category)
addReference(category, { id: data.id, thumbnail: base64 });
// Note: Store uses 'thumbnail' property for the image data
addReference(category, { id: refId, thumbnail: base64 });
// Add to history
const newItem = {
id: data.id,
url: base64, // For local display history we use base64. Ideally we'd valid URL but this works for session.
id: refId,
url: base64,
category: category,
originalName: file.name
};
// exist check?
const exists = history.find(h => h.id === data.id);
if (!exists) {
setHistory([newItem, ...history]);
}
} else {
console.error("Upload failed details:", JSON.stringify(data));
alert(`Upload failed: ${data.error}\n\nDetails: ${JSON.stringify(data) || 'Check console'}`);
const exists = history.find(h => h.id === refId);
if (!exists) {
setHistory([newItem, ...history].slice(0, 50));
}
}
setUploadingRefs(prev => ({ ...prev, [category]: false }));
};
@ -344,11 +434,11 @@ export function PromptHero() {
);
return (
<div className="w-full max-w-4xl mx-auto my-8 md:my-12 px-4">
<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-4 rounded-xl border flex items-start gap-3 animate-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/30 text-amber-200"
: "bg-red-500/10 border-red-500/30 text-red-200"
@ -373,79 +463,62 @@ export function PromptHero() {
)}
<div className={cn(
"relative flex flex-col gap-4 rounded-3xl bg-[#1A1A1E]/90 bg-gradient-to-b from-white/[0.02] to-transparent p-6 shadow-2xl border border-white/5 backdrop-blur-sm transition-all",
"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"
)}>
{/* Header / Title + Provider Toggle */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-2xl bg-gradient-to-br from-amber-500/20 to-purple-600/20 border border-white/5 flex items-center justify-center">
{settings.provider === 'grok' ? (
<Zap className="h-6 w-6 text-yellow-400" />
) : settings.provider === 'meta' ? (
<Brain className="h-6 w-6 text-blue-400" />
<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-6 w-6 text-amber-300" />
<Sparkles className="h-4 w-4 text-amber-300" />
)}
</div>
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Create & Remix</h2>
<p className="text-xs text-white/50 font-medium">
Powered by <span className={cn(
settings.provider === 'grok' ? "text-yellow-400" :
<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 === 'grok' ? 'Grok (xAI)' :
settings.provider === 'meta' ? 'Meta AI' :
'Google Whisk'}
)}>
{settings.provider === 'meta' ? 'Meta AI' :
'Whisk'}
</span>
</span>
</p>
</h2>
</div>
</div>
{/* Provider Toggle */}
<div className="flex bg-black/40 p-1 rounded-xl border border-white/10 backdrop-blur-md">
<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: 'whisk' })}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
"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"
>
<Sparkles className="h-3.5 w-3.5" />
<Sparkles className="h-3 w-3" />
<span className="hidden sm:inline">Whisk</span>
</button>
<button
onClick={() => setSettings({ provider: 'grok' })}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
settings.provider === 'grok'
? "bg-white/10 text-white shadow-sm"
: "text-white/40 hover:text-white/70 hover:bg-white/5"
)}
title="Grok (xAI)"
>
<Zap className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Grok</span>
</button>
<button
onClick={() => setSettings({ provider: 'meta' })}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
"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.5 w-3.5" />
<Brain className="h-3 w-3" />
<span className="hidden sm:inline">Meta</span>
</button>
</div>
@ -453,81 +526,83 @@ export function PromptHero() {
{/* Input Area */}
<div className="relative group">
<div className="absolute -inset-0.5 bg-gradient-to-r from-amber-500/20 to-purple-600/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition duration-500" />
<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}
placeholder="Describe your imagination... (e.g. 'A futuristic city with flying cars')"
className="relative w-full resize-none bg-[#0E0E10] rounded-xl p-5 text-base md:text-lg text-white placeholder:text-white/20 outline-none min-h-[120px] border border-white/10 focus:border-purple-500/50 transition-all shadow-inner"
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>
{/* Controls Area */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 pt-2">
<div className="flex flex-col md:flex-row items-center justify-between gap-3 pt-1">
{/* Left Controls: References */}
{/* For Meta AI: Only Subject is enabled (for video generation), Scene/Style disabled */}
<div className="flex flex-wrap gap-2">
{(['subject', 'scene', 'style'] as ReferenceCategory[]).map((cat) => {
const refs = references[cat] || [];
const hasRefs = refs.length > 0;
const isUploading = uploadingRefs[cat];
return (
<div key={cat} className="relative group">
<button
onClick={() => toggleReference(cat)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, cat)}
className={cn(
"flex items-center gap-2 rounded-full px-4 py-2 text-xs 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-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : hasRefs ? (
<div className="flex -space-x-2">
{refs.slice(0, 4).map((ref, idx) => (
<img
key={ref.id}
src={ref.thumbnail}
alt=""
className="h-5 w-5 rounded-sm object-cover ring-1 ring-white/20"
style={{ zIndex: 10 - idx }}
/>
))}
{refs.length > 4 && (
<div className="h-5 w-5 rounded-sm bg-purple-500/50 flex items-center justify-center text-[9px] font-bold ring-1 ring-white/20">
+{refs.length - 4}
</div>
)}
</div>
) : (
<Upload className="h-4 w-4" />
)}
<span className="capitalize tracking-wide">{cat}</span>
{refs.length > 0 && (
<span className="text-[10px] bg-purple-500/30 text-purple-100 rounded-full px-1.5 h-4 flex items-center">{refs.length}</span>
)}
</button>
{/* Clear all button */}
{hasRefs && !isUploading && (
{((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
className="absolute -top-1 -right-1 p-1 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`}
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"
)}
>
<X className="h-2.5 w-2.5" />
{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>
)}
</div>
);
})}
{/* 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>
{/* Hidden file inputs for upload */}
@ -556,68 +631,83 @@ export function PromptHero() {
onChange={(e) => handleFileInputChange(e, 'style')}
/>
{/* Right Controls: Settings & Generate */}
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto justify-end">
<div className="flex flex-wrap items-center gap-2 w-full md:w-auto justify-end">
{/* Settings Group */}
<div className="flex items-center gap-1 bg-[#0E0E10] p-1.5 rounded-full border border-white/10">
<div className="flex items-center gap-0.5 bg-[#0E0E10] p-1 rounded-lg border border-white/10">
{/* Image Count */}
<button
onClick={cycleImageCount}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors"
title="Number of images"
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.5 w-3.5 opacity-70" />
<span>{settings.imageCount}</span>
<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" />
<div className="w-px h-3 bg-white/10 mx-1" />
{/* Aspect Ratio */}
<button
onClick={nextAspectRatio}
className="px-3 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white hover:bg-white/5 transition-colors"
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" />
<div className="w-px h-3 bg-white/10 mx-1" />
{/* Precise Mode */}
<button
onClick={() => setSettings({ preciseMode: !settings.preciseMode })}
className={cn(
"px-3 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5",
"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: Uses images directly as visual reference"
title="Precise Mode"
>
<span>🍌</span>
{/* <span>🍌</span> */}
<span>Precise</span>
</button>
</div>
{/* Generate Button */}
<GradientButton
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Creating...</span>
</>
) : (
<>
<Sparkles className="h-4 w-4" />
<span>Create</span>
</>
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"
)}
</GradientButton>
>
<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>

View file

@ -164,8 +164,24 @@ 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);
@ -237,23 +253,42 @@ export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => voi
))}
</div>
{/* Sub-Categories (only show if NOT history/foryou to keep clean? Or keep it?) */}
{/* Sub-Categories */}
{sortMode === 'all' && (
<div className="flex flex-wrap gap-2">
{uniqueCategories.map(cat => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
selectedCategory === cat
? "bg-primary text-primary-foreground"
: "bg-card hover:bg-secondary text-muted-foreground"
)}
>
{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>
)}
@ -276,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,14 +2,13 @@
import React from 'react';
import { useStore } from '@/lib/store';
import { Save, Sparkles, Zap, Brain } from 'lucide-react';
import { Save, Sparkles, Brain, Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type Provider = 'whisk' | 'grok' | 'meta';
type Provider = 'whisk' | 'meta';
const providers: { id: Provider; name: string; icon: any; description: string }[] = [
{ id: 'whisk', name: 'Google Whisk', icon: Sparkles, description: 'ImageFX / Imagen 3' },
{ id: 'grok', name: 'Grok (xAI)', icon: Zap, description: 'FLUX.1 model' },
{ id: 'meta', name: 'Meta AI', icon: Brain, description: 'Imagine / Emu' },
];
@ -19,18 +18,20 @@ export function Settings() {
// Local state for form fields
const [provider, setProvider] = React.useState<Provider>(settings.provider || 'whisk');
const [whiskCookies, setWhiskCookies] = React.useState(settings.whiskCookies || '');
const [grokApiKey, setGrokApiKey] = React.useState(settings.grokApiKey || '');
const [grokCookies, setGrokCookies] = React.useState(settings.grokCookies || '');
const [useMetaFreeWrapper, setUseMetaFreeWrapper] = React.useState(settings.useMetaFreeWrapper !== undefined ? settings.useMetaFreeWrapper : true);
const [metaFreeWrapperUrl, setMetaFreeWrapperUrl] = React.useState(settings.metaFreeWrapperUrl || 'http://localhost:8000');
const [metaCookies, setMetaCookies] = React.useState(settings.metaCookies || '');
const [facebookCookies, setFacebookCookies] = React.useState(settings.facebookCookies || '');
const [saved, setSaved] = React.useState(false);
const handleSave = () => {
setSettings({
provider,
whiskCookies,
grokApiKey,
grokCookies,
metaCookies
useMetaFreeWrapper,
metaFreeWrapperUrl,
metaCookies,
facebookCookies
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
@ -89,56 +90,79 @@ export function Settings() {
</div>
)}
{provider === 'grok' && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Grok API Key (Recommended)</label>
<input
type="password"
value={grokApiKey}
onChange={(e) => setGrokApiKey(e.target.value)}
placeholder="xai-..."
className="w-full p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Get your API key from <a href="https://console.x.ai" target="_blank" className="underline hover:text-primary">console.x.ai</a>
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-card px-2 text-muted-foreground">or use cookies</span>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">Grok Cookies (Alternative)</label>
<textarea
value={grokCookies}
onChange={(e) => setGrokCookies(e.target.value)}
placeholder="Paste cookies from grok.com..."
className="w-full h-24 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://grok.com" target="_blank" className="underline hover:text-primary">grok.com</a> session.
</p>
</div>
</div>
)}
{provider === 'meta' && (
<div className="space-y-2">
<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 (requires Facebook login).
</p>
<div className="space-y-4">
{/* 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={() => 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"}`}
>
<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>
{useMetaFreeWrapper && (
<div className="space-y-2">
<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>
</details>
<div className="pt-2 border-t border-white/5">
<p className="text-sm font-medium mb-3 text-amber-400">Authentication Required</p>
{/* Meta AI Cookies */}
<div className="space-y-2 mb-4">
<label className="text-sm font-medium">Meta.ai Cookies</label>
<textarea
value={metaCookies}
onChange={(e) => setMetaCookies(e.target.value)}
placeholder="Paste cookies from meta.ai..."
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Get from logged-in <a href="https://www.meta.ai" target="_blank" className="underline hover:text-primary">meta.ai</a> session.
</p>
</div>
{/* Facebook Cookies */}
<div className="space-y-2">
<label className="text-sm font-medium">Facebook.com Cookies <span className="text-red-500">*</span></label>
<textarea
value={facebookCookies}
onChange={(e) => setFacebookCookies(e.target.value)}
placeholder="Paste cookies from facebook.com (REQUIRED for authentication)..."
className="w-full h-32 p-3 rounded-lg bg-secondary/50 border border-border focus:ring-2 focus:ring-primary/50 outline-none font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
<strong>Required:</strong> Meta AI authenticates via Facebook. Get from logged-in <a href="https://www.facebook.com" target="_blank" className="underline hover:text-primary">facebook.com</a> session using Cookie-Editor.
</p>
</div>
</div>
</div>
)}
</div>

View file

@ -72,7 +72,7 @@ export function VideoPromptModal({ isOpen, onClose, image, onGenerate }: VideoPr
{image && (
<>
<img
src={`data:image/png;base64,${image.data}`}
src={image.data.startsWith('data:') || image.data.startsWith('http') ? image.data : `data:image/png;base64,${image.data}`}
alt="Source"
className="w-full h-full object-cover"
/>

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

@ -7,3 +7,10 @@ services:
- "8558:3000"
environment:
- NODE_ENV=production
metaai-free-api:
build: ./services/metaai-api
container_name: metaai-free-api
restart: unless-stopped
ports:
- "8000:8000"

View file

@ -1,300 +1,54 @@
import { Prompt } from './types';
import { Prompt } from '@/lib/types';
import fs from 'fs/promises';
import path from 'path';
const JIMMYLV_SOURCE_URL = "https://raw.githubusercontent.com/JimmyLv/awesome-nano-banana/main/cases";
const YOUMIND_README_URL = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main/README.md";
const ZEROLU_README_URL = "https://raw.githubusercontent.com/ZeroLu/awesome-nanobanana-pro/main/README.md";
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 [];
}
}
}
const MAX_CASE_ID = 200; // Increased limit slightly
const BATCH_SIZE = 10;
export class JimmyLvCrawler {
async crawl(limit: number = 300): Promise<Prompt[]> {
console.log(`Starting crawl for ${limit} cases...`);
const prompts: Prompt[] = [];
// Create batches of IDs to fetch
const ids = Array.from({ length: limit }, (_, i) => i + 1);
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const batch = ids.slice(i, i + BATCH_SIZE);
// console.log(`Fetching batch ${i + 1} to ${i + batch.length}...`);
const results = await Promise.all(
batch.map(id => this.fetchCase(id))
);
results.forEach(p => {
if (p) prompts.push(p);
});
}
console.log(`[JimmyLv] Crawled ${prompts.length} valid prompts.`);
return prompts;
}
private async fetchCase(id: number): Promise<Prompt | null> {
try {
const url = `${JIMMYLV_SOURCE_URL}/${id}/case.yml`;
const res = await fetch(url);
if (!res.ok) {
// console.warn(`Failed to fetch ${url}: ${res.status}`);
return null;
}
const text = await res.text();
return this.parseCase(text, id);
} catch (error) {
console.error(`Error fetching case ${id}:`, error);
return null;
}
}
private parseCase(content: string, caseId: number): Prompt | null {
try {
// Extract title
let title = this.extract(content, /title_en:\s*(.+)/);
if (!title) title = this.extract(content, /title:\s*(.+)/) || "Unknown";
// Extract prompt (Multi-line block scalar)
let promptText = "";
const promptMatch = content.match(/prompt_en:\s*\|\s*\n((?: .+\n)+)/) ||
content.match(/prompt:\s*\|\s*\n((?: .+\n)+)/);
if (promptMatch) {
promptText = promptMatch[1]
.split('\n')
.map(line => line.trim())
.join(' ')
.trim();
}
if (!promptText) {
// Try simpler single line prompt
promptText = this.extract(content, /prompt:\s*(.+)/) || "";
}
if (!promptText) return null;
// Extract image filename
const imageFilename = this.extract(content, /image:\s*(.+)/);
let imageUrl = "";
if (imageFilename) {
imageUrl = `${JIMMYLV_SOURCE_URL}/${caseId}/${imageFilename}`;
}
// Extract author
const author = this.extract(content, /author:\s*"?([^"\n]+)"?/) || "JimmyLv Repo";
const category = this.inferCategory(title, promptText);
return {
id: 0, // Will be assigned by manager
title: title.slice(0, 150),
prompt: promptText,
category,
category_type: "style", // Simplified
description: promptText.slice(0, 200) + (promptText.length > 200 ? "..." : ""),
images: imageUrl ? [imageUrl] : [],
author,
source: "jimmylv",
source_url: `https://github.com/JimmyLv/awesome-nano-banana/tree/main/cases/${caseId}`
};
} catch (error) {
return null;
}
}
private extract(content: string, regex: RegExp): string | null {
const match = content.match(regex);
return match ? match[1].trim() : null;
}
private inferCategory(title: string, prompt: string): string {
const text = (title + " " + prompt).toLowerCase();
const rules: [string[], string][] = [
[["ghibli", "anime", "cartoon", "chibi", "comic", "illustration", "drawing"], "Illustration"],
[["icon", "logo", "symbol"], "Logo / Icon"],
[["product", "packaging", "mockup"], "Product"],
[["avatar", "profile", "headshot"], "Profile / Avatar"],
[["infographic", "chart", "diagram"], "Infographic / Edu Visual"],
[["cinematic", "film", "movie"], "Cinematic / Film Still"],
[["3d", "render", "blender"], "3D Render"],
[["pixel", "8-bit", "retro game"], "Pixel Art"],
];
for (const [keywords, cat] of rules) {
if (keywords.some(k => text.includes(k))) return cat;
}
return "Photography";
async crawl(): Promise<Prompt[]> {
console.log("[JimmyLvCrawler] Crawling not implemented");
return [];
}
}
export class YouMindCrawler {
async crawl(): Promise<Prompt[]> {
console.log(`[YouMind] Starting crawl of README...`);
const prompts: Prompt[] = [];
try {
const res = await fetch(YOUMIND_README_URL);
if (!res.ok) throw new Error("Failed to fetch YouMind README");
const text = await res.text();
// Split by "### No." sections
const sections = text.split(/### No\./g).slice(1);
let idCounter = 1;
for (const section of sections) {
const prompt = this.parseSection(section, idCounter++);
if (prompt) prompts.push(prompt);
}
} catch (e) {
console.error("[YouMind] Crawl failed", e);
}
console.log(`[YouMind] Crawled ${prompts.length} valid prompts.`);
return prompts;
}
private parseSection(content: string, index: number): Prompt | null {
try {
// Title: First line after number
const titleMatch = content.match(/\s*\d+:\s*(.+)/);
const title = titleMatch ? titleMatch[1].trim() : `YouMind Case ${index}`;
// Prompt Block
const promptMatch = content.match(/```\s*([\s\S]*?)\s*```/);
// Some sections might have multiple blocks, assume first large one is prompt?
// The README format shows prompt in a code block under #### 📝 Prompt
// Better regex: look for #### 📝 Prompt\n\n```\n...
const strictPromptMatch = content.match(/#### 📝 Prompt\s+```[\s\S]*?\n([\s\S]*?)```/);
const promptText = strictPromptMatch ? strictPromptMatch[1].trim() : (promptMatch ? promptMatch[1].trim() : "");
if (!promptText) return null;
// Images
const imageMatches = [...content.matchAll(/<img src="(.*?)"/g)];
const images = imageMatches.map(m => m[1]).filter(url => !url.includes("img.shields.io")); // Exclude badges
// Author / Source
const authorMatch = content.match(/- \*\*Author:\*\* \[(.*?)\]/);
const author = authorMatch ? authorMatch[1] : "YouMind Community";
const sourceMatch = content.match(/- \*\*Source:\*\* \[(.*?)\]\((.*?)\)/);
const sourceUrl = sourceMatch ? sourceMatch[2] : `https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts#no-${index}`;
return {
id: 0,
title,
prompt: promptText,
category: this.inferCategory(title, promptText),
category_type: "style",
description: title,
images,
author,
source: "youmind",
source_url: sourceUrl
};
} catch (e) {
return null;
}
}
private inferCategory(title: string, prompt: string): string {
// Reuse similar logic, maybe static util later
const text = (title + " " + prompt).toLowerCase();
if (text.includes("logo") || text.includes("icon")) return "Logo / Icon";
if (text.includes("3d")) return "3D Render";
if (text.includes("photo") || text.includes("realistic")) return "Photography";
return "Illustration";
console.log("[YouMindCrawler] Crawling not implemented");
return [];
}
}
export class ZeroLuCrawler {
async crawl(): Promise<Prompt[]> {
console.log(`[ZeroLu] Starting crawl of README...`);
const prompts: Prompt[] = [];
try {
const res = await fetch(ZEROLU_README_URL);
if (!res.ok) throw new Error("Failed to fetch ZeroLu README");
const text = await res.text();
// Split by H3 headers like "### 1.1 " or "### 1.2 "
// The format is `### X.X. Title`
const sections = text.split(/### \d+\.\d+\.?\s+/).slice(1);
// We need to capture the title which was consumed by split, or use matchAll
// Better to use regex global match to find headers and their content positions.
// Or just split and accept title is lost? No, title is important.
// Alternative loop:
const regex = /### (\d+\.\d+\.?\s+.*?)\n([\s\S]*?)(?=### \d+\.\d+|$)/g;
let match;
let count = 0;
while ((match = regex.exec(text)) !== null) {
const title = match[1].trim();
const body = match[2];
const prompt = this.parseSection(title, body);
if (prompt) prompts.push(prompt);
count++;
}
} catch (e) {
console.error("[ZeroLu] Crawl failed", e);
}
console.log(`[ZeroLu] Crawled ${prompts.length} valid prompts.`);
return prompts;
}
private parseSection(title: string, content: string): Prompt | null {
// Extract Prompt
// Format: **Prompt:**\n\n```\n...\n```
const promptMatch = content.match(/\*\*Prompt:\*\*\s*[\n\r]*```[\w]*([\s\S]*?)```/);
if (!promptMatch) return null;
const promptText = promptMatch[1].trim();
// Extract Images
// Markdown image: ![...](url) or HTML <img src="...">
const mdImageMatch = content.match(/!\[.*?\]\((.*?)\)/);
const htmlImageMatch = content.match(/<img.*?src="(.*?)".*?>/);
let imageUrl = mdImageMatch ? mdImageMatch[1] : (htmlImageMatch ? htmlImageMatch[1] : "");
// Clean URL if it has query params (sometimes github adds them) unless needed
// Assuming raw github images work fine.
// Source
const sourceMatch = content.match(/Source: \[@(.*?)\]\((.*?)\)/);
const sourceUrl = sourceMatch ? sourceMatch[2] : `https://github.com/ZeroLu/awesome-nanobanana-pro#${title.toLowerCase().replace(/\s+/g, '-')}`;
const author = sourceMatch ? sourceMatch[1] : "ZeroLu Community";
return {
id: 0,
title,
prompt: promptText,
category: this.inferCategory(title, promptText),
category_type: "style",
description: title,
images: imageUrl ? [imageUrl] : [],
author,
source: "zerolu",
source_url: sourceUrl
};
}
private inferCategory(title: string, prompt: string): string {
const text = (title + " " + prompt).toLowerCase();
if (text.includes("logo") || text.includes("icon")) return "Logo / Icon";
if (text.includes("3d")) return "3D Render";
if (text.includes("photo") || text.includes("realistic") || text.includes("selfie")) return "Photography";
return "Illustration";
console.log("[ZeroLuCrawler] Crawling not implemented");
return [];
}
}

View file

@ -2,10 +2,11 @@ import Dexie, { Table } from 'dexie';
export interface ImageItem {
id?: number;
data: string; // Base64
data: string; // Base64 or URL
prompt: string;
aspectRatio: string;
createdAt: number;
provider?: 'whisk' | 'meta'; // Track which AI generated the image
}
export class KeyValuePixDB extends Dexie {

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

@ -1,246 +0,0 @@
/**
* Grok/xAI Client for Image Generation
*
* Supports two authentication methods:
* 1. Official API Key from console.x.ai (recommended)
* 2. Cookie-based auth from logged-in grok.com session
*
* Image Model: FLUX.1 by Black Forest Labs
*/
// Official xAI API endpoint
const XAI_API_BASE = "https://api.x.ai/v1";
// Grok web interface endpoint (for cookie-based auth)
const GROK_WEB_BASE = "https://grok.com";
interface GrokGenerateOptions {
prompt: string;
apiKey?: string;
cookies?: string;
numImages?: number;
}
interface GrokImageResult {
url: string;
data?: string; // base64
prompt: string;
model: string;
}
export class GrokClient {
private apiKey?: string;
private cookies?: string;
constructor(options: { apiKey?: string; cookies?: string }) {
this.apiKey = options.apiKey;
this.cookies = this.normalizeCookies(options.cookies);
}
/**
* Normalize cookies from string or JSON format
* Handles cases where user pastes JSON array from extension/devtools
*/
private normalizeCookies(cookies?: string): string | undefined {
if (!cookies) return undefined;
try {
// Check if it looks like JSON
if (cookies.trim().startsWith('[')) {
const parsed = JSON.parse(cookies);
if (Array.isArray(parsed)) {
return parsed
.map((c: any) => `${c.name}=${c.value}`)
.join('; ');
}
}
} catch (e) {
// Not JSON, assume string
}
return cookies;
}
/**
* Generate images using Grok/xAI
* Prefers official API if apiKey is provided, falls back to cookie-based
*/
async generate(prompt: string, numImages: number = 1): Promise<GrokImageResult[]> {
if (this.apiKey) {
return this.generateWithAPI(prompt, numImages);
} else if (this.cookies) {
return this.generateWithCookies(prompt, numImages);
} else {
throw new Error("Grok: No API key or cookies provided. Configure in Settings.");
}
}
/**
* Generate using official xAI API (recommended)
* Requires API key from console.x.ai
*/
private async generateWithAPI(prompt: string, numImages: number): Promise<GrokImageResult[]> {
console.log(`[Grok API] Generating ${numImages} image(s) for: "${prompt.substring(0, 50)}..."`);
const response = await fetch(`${XAI_API_BASE}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.apiKey}`
},
body: JSON.stringify({
model: "grok-2-image",
prompt: prompt,
n: numImages,
response_format: "url" // or "b64_json"
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Grok API] Error:", response.status, errorText);
throw new Error(`Grok API Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("[Grok API] Response:", JSON.stringify(data, null, 2));
// Parse response - xAI uses OpenAI-compatible format
const images: GrokImageResult[] = (data.data || []).map((img: any) => ({
url: img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : ''),
data: img.b64_json,
prompt: prompt,
model: "grok-2-image"
}));
if (images.length === 0) {
throw new Error("Grok API returned no images");
}
return images;
}
/**
* Generate using Grok web interface (cookie-based)
* Requires cookies from logged-in grok.com session
*/
private async generateWithCookies(prompt: string, numImages: number): Promise<GrokImageResult[]> {
console.log(`[Grok Web] Generating image for: "${prompt.substring(0, 50)}..."`);
// The Grok web interface uses a chat-based API
// We need to send a message asking for image generation
const imagePrompt = `Generate an image: ${prompt}`;
const response = await fetch(`${GROK_WEB_BASE}/rest/app-chat/conversations/new`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cookie": this.cookies!,
"Origin": GROK_WEB_BASE,
"Referer": `${GROK_WEB_BASE}/`,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty"
},
body: JSON.stringify({
temporary: false,
modelName: "grok-3",
message: imagePrompt,
fileAttachments: [],
imageAttachments: [],
disableSearch: false,
enableImageGeneration: true,
returnImageBytes: false,
returnRawGrokInXaiRequest: false,
sendFinalMetadata: true,
customInstructions: "",
deepsearchPreset: "",
isReasoning: false
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Grok Web] Error:", response.status, errorText);
throw new Error(`Grok Web Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
// Parse streaming response to find image URLs
const text = await response.text();
console.log("[Grok Web] Response length:", text.length);
// Look for generated image URLs in the response
const imageUrls = this.extractImageUrls(text);
if (imageUrls.length === 0) {
console.warn("[Grok Web] No image URLs found in response. Response preview:", text.substring(0, 500));
throw new Error("Grok did not generate any images. Try a different prompt or check your cookies.");
}
return imageUrls.map(url => ({
url,
prompt,
model: "grok-3"
}));
}
/**
* Extract image URLs from Grok's streaming response
*/
private extractImageUrls(responseText: string): string[] {
const urls: string[] = [];
// Try to parse as JSON lines (NDJSON format)
const lines = responseText.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const data = JSON.parse(line);
// Check for generatedImageUrls field
if (data.generatedImageUrls && Array.isArray(data.generatedImageUrls)) {
urls.push(...data.generatedImageUrls);
}
// Check for imageUrls in result
if (data.result?.imageUrls) {
urls.push(...data.result.imageUrls);
}
// Check for media attachments
if (data.attachments) {
for (const attachment of data.attachments) {
if (attachment.type === 'image' && attachment.url) {
urls.push(attachment.url);
}
}
}
} catch {
// Not JSON, try regex extraction
}
}
// Fallback: regex for image URLs
if (urls.length === 0) {
const urlRegex = /https:\/\/[^"\s]+\.(png|jpg|jpeg|webp)/gi;
const matches = responseText.match(urlRegex);
if (matches) {
urls.push(...matches);
}
}
// Deduplicate
return [...new Set(urls)];
}
/**
* Download image from URL and convert to base64
*/
async downloadAsBase64(url: string): Promise<string> {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
return base64;
}
}

View file

@ -27,15 +27,31 @@ interface MetaSession {
lsd?: string;
fb_dtsg?: string;
accessToken?: string;
externalConversationId?: string;
}
// Aspect ratio types for Meta AI
export type AspectRatio = 'portrait' | 'landscape' | 'square';
const ORIENTATION_MAP: Record<AspectRatio, string> = {
'portrait': 'VERTICAL', // 9:16
'landscape': 'HORIZONTAL', // 16:9
'square': 'SQUARE' // 1:1
};
export class MetaAIClient {
private cookies: string;
private session: MetaSession = {};
private useFreeWrapper: boolean = true;
private freeWrapperUrl: string = 'http://localhost:8000';
constructor(options: MetaAIOptions) {
constructor(options: MetaAIOptions & { useFreeWrapper?: boolean; freeWrapperUrl?: string }) {
this.cookies = this.normalizeCookies(options.cookies);
this.parseSessionFromCookies();
this.useFreeWrapper = options.useFreeWrapper !== undefined ? options.useFreeWrapper : true;
this.freeWrapperUrl = options.freeWrapperUrl || 'http://localhost:8000';
console.log("[Meta AI] Cookie string length:", this.cookies.length);
if (this.cookies) {
this.parseSessionFromCookies();
}
}
/**
@ -79,11 +95,32 @@ export class MetaAIClient {
}
}
/**
* Get the initialized session tokens (call initSession first if not done)
*/
async getSession(): Promise<MetaSession & { externalConversationId?: string }> {
if (!this.useFreeWrapper && !this.session.lsd && !this.session.fb_dtsg) {
await this.initSession();
}
return this.session;
}
/**
* Get the normalized cookie string
*/
getCookies(): string {
return this.cookies;
}
/**
* Generate images using Meta AI's Imagine model
*/
async generate(prompt: string, numImages: number = 4): Promise<MetaImageResult[]> {
console.log(`[Meta AI] Generating images for: "${prompt.substring(0, 50)}..."`);
async generate(prompt: string, numImages: number = 4, aspectRatio: AspectRatio = 'portrait'): Promise<MetaImageResult[]> {
console.log(`[Meta AI] Generating images for: "${prompt.substring(0, 50)}..." (${aspectRatio})`);
if (this.useFreeWrapper) {
return this.generateWithFreeWrapper(prompt, numImages);
}
// First, get the access token and session info if not already fetched
if (!this.session.accessToken) {
@ -95,8 +132,8 @@ export class MetaAIClient {
? prompt
: `Imagine ${prompt}`;
// Send the prompt via GraphQL
const response = await this.sendPrompt(imagePrompt);
// Send the prompt via GraphQL with aspect ratio
const response = await this.sendPrompt(imagePrompt, aspectRatio);
// Extract image URLs from response
const images = this.extractImages(response, prompt);
@ -111,6 +148,92 @@ export class MetaAIClient {
return images;
}
/**
* Generate using free API wrapper (mir-ashiq/metaai-api)
* Connects to local docker service
*/
/**
* Generate using free API wrapper (mir-ashiq/metaai-api)
* Connects to local docker service
*/
private async generateWithFreeWrapper(prompt: string, numImages: number): Promise<MetaImageResult[]> {
console.log(`[Meta Wrapper] Generating image for: "${prompt.substring(0, 50)}..." via ${this.freeWrapperUrl}`);
const cookieDict = this.parseCookiesToDict(this.cookies);
const response = await fetch(`${this.freeWrapperUrl}/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
message: `Imagine ${prompt}`,
stream: false,
cookies: cookieDict
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("[Meta Wrapper] Error:", response.status, errorText);
throw new Error(`Meta Wrapper Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("[Meta Wrapper] Response:", JSON.stringify(data, null, 2).substring(0, 500));
// Check for images in response
const images: MetaImageResult[] = [];
// mir-ashiq/metaai-api returns { media: [{ url: "...", ... }] }
if (data.media && Array.isArray(data.media)) {
data.media.forEach((m: any) => {
if (m.url) images.push({ url: m.url, prompt, model: "meta-wrapper" });
});
}
// Fallback checks
if (images.length === 0 && data.images && Array.isArray(data.images)) {
data.images.forEach((url: string) => {
images.push({ url, prompt, model: "meta-wrapper" });
});
}
if (images.length === 0 && data.sources && Array.isArray(data.sources)) {
data.sources.forEach((s: any) => {
if (s.url && (s.url.includes('.jpg') || s.url.includes('.png') || s.url.includes('.webp'))) {
images.push({ url: s.url, prompt, model: "meta-wrapper-source" });
}
});
}
if (images.length === 0) {
console.warn("[Meta Wrapper] No images found via /chat endpoint", data);
throw new Error("Meta Wrapper returned no images. Please check if the prompt triggered image generation.");
}
return images;
}
private parseCookiesToDict(cookieStr: string): Record<string, string> {
const dict: Record<string, string> = {};
if (!cookieStr) return dict;
// Handle basic key=value; format
cookieStr.split(';').forEach(pair => {
const [key, ...rest] = pair.trim().split('=');
if (key && rest.length > 0) {
dict[key] = rest.join('=');
}
});
return dict;
}
private async generateWithFreeWrapperFallback(prompt: string): Promise<MetaImageResult[]> {
// Fallback logic not needed if /chat works
throw new Error("Meta Wrapper endpoint /chat failed.");
}
/**
* Initialize session - get access token from meta.ai page
*/
@ -166,28 +289,63 @@ export class MetaAIClient {
this.session.fb_dtsg = dtsgMatch[1];
}
// We no longer strictly enforce accessToken presence here
// as some requests might work with just cookies
// Enhanced logging for debugging
console.log("[Meta AI] Session tokens extracted:", {
hasAccessToken: !!this.session.accessToken,
hasLsd: !!this.session.lsd,
hasDtsg: !!this.session.fb_dtsg
});
if (!this.session.accessToken && !this.session.lsd) {
console.warn("[Meta AI] CRITICAL: No authentication tokens found. Check if cookies are valid.");
}
}
/**
* Send prompt via GraphQL mutation
*/
private async sendPrompt(prompt: string): Promise<any> {
/**
* Send prompt via GraphQL mutation (Abra - for Image Generation)
* Using EXACT variable structure from Python Strvm/meta-ai-api library
*/
private async sendPrompt(prompt: string, aspectRatio: AspectRatio = 'portrait'): Promise<any> {
// Generate external conversation ID (UUID) and offline threading ID (Snowflake-like)
const externalConversationId = crypto.randomUUID();
const timestamp = Date.now();
const randomPart = Math.floor(Math.random() * 4194304); // 22 bits
const offlineThreadingId = ((BigInt(timestamp) << BigInt(22)) | BigInt(randomPart)).toString();
// Store for polling
this.session.externalConversationId = externalConversationId;
// Map aspect ratio to Meta AI orientation
const orientation = ORIENTATION_MAP[aspectRatio];
const variables = {
message: {
text: prompt,
content_type: "TEXT"
sensitive_string_value: prompt // Python uses sensitive_string_value, not text!
},
source: "PDT_CHAT_INPUT",
external_message_id: Math.random().toString(36).substring(2) + Date.now().toString(36)
externalConversationId: externalConversationId,
offlineThreadingId: offlineThreadingId,
suggestedPromptIndex: null,
flashVideoRecapInput: { images: [] },
flashPreviewInput: null,
promptPrefix: null,
entrypoint: "ABRA__CHAT__TEXT",
icebreaker_type: "TEXT",
imagineClientOptions: { orientation: orientation }, // Aspect ratio control
__relay_internal__pv__AbraDebugDevOnlyrelayprovider: false,
__relay_internal__pv__WebPixelRatiorelayprovider: 1
};
console.log("[Meta AI] Sending Variables:", JSON.stringify(variables, null, 2));
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useAbraSendMessageMutation",
variables: JSON.stringify(variables),
doc_id: "7783822248314888",
server_timestamps: "true",
doc_id: "7783822248314888", // Abra Mutation ID
...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
});
@ -209,25 +367,56 @@ export class MetaAIClient {
body: body.toString()
});
const rawText = await response.text();
console.log("[Meta AI] Response received, parsing streaming data...");
if (!response.ok) {
const errorText = await response.text();
console.error("[Meta AI] GraphQL Error:", response.status, errorText);
throw new Error(`Meta AI Error: ${response.status} - ${errorText.substring(0, 200)}`);
throw new Error(`Meta AI Error: ${response.status} - ${rawText.substring(0, 500)}`);
}
// Check if response is actually JSON
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("text/html")) {
const text = await response.text();
if (text.includes("login_form") || text.includes("facebook.com/login")) {
// Meta AI returns streaming response (multiple JSON lines)
// We need to find the final response where streaming_state === "OVERALL_DONE"
let lastValidResponse: any = null;
const lines = rawText.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
// Check for streaming state in both direct and nested paths
const streamingState =
parsed?.data?.xfb_abra_send_message?.bot_response_message?.streaming_state ||
parsed?.data?.node?.bot_response_message?.streaming_state;
if (streamingState === "OVERALL_DONE") {
console.log("[Meta AI] Found OVERALL_DONE response");
lastValidResponse = parsed;
break;
}
// Keep track of any valid response with imagine_card
const imagineCard =
parsed?.data?.xfb_abra_send_message?.bot_response_message?.imagine_card ||
parsed?.data?.node?.bot_response_message?.imagine_card;
if (imagineCard?.session?.media_sets) {
lastValidResponse = parsed;
}
} catch (e) {
// Skip non-JSON lines
continue;
}
}
if (!lastValidResponse) {
if (rawText.includes("login_form") || rawText.includes("facebook.com/login")) {
throw new Error("Meta AI: Session expired. Please refresh your cookies.");
}
throw new Error(`Meta AI returned HTML error: ${text.substring(0, 100)}...`);
throw new Error("Meta AI: No valid response found in streaming data");
}
const data = await response.json();
console.log("[Meta AI] Response:", JSON.stringify(data, null, 2).substring(0, 500));
return data;
console.log("[Meta AI] Successfully parsed streaming response");
return lastValidResponse;
}
/**
@ -237,24 +426,88 @@ export class MetaAIClient {
const images: MetaImageResult[] = [];
// Navigate through the response structure
const messageData = response?.data?.node?.bot_response_message ||
// Abra streaming: xfb_abra_send_message.bot_response_message
// Abra direct: node.bot_response_message
// Kadabra: useKadabraSendMessageMutation.node.bot_response_message
const messageData = response?.data?.xfb_abra_send_message?.bot_response_message ||
response?.data?.useKadabraSendMessageMutation?.node?.bot_response_message ||
response?.data?.node?.bot_response_message ||
response?.data?.xabraAIPreviewMessageSendMutation?.message;
if (!messageData) {
return images;
// For Polling/KadabraPromptRootQuery which also contains imagine_cards
const pollMessages = response?.data?.kadabra_prompt?.messages?.edges || [];
if (pollMessages.length > 0) {
for (const edge of pollMessages) {
const nodeImages = this.extractImagesFromMessage(edge?.node, originalPrompt);
images.push(...nodeImages);
}
}
if (messageData) {
images.push(...this.extractImagesFromMessage(messageData, originalPrompt));
}
// --- STRENGTHENED FALLBACK ---
// If still no images, but we have data, do a recursive search for any CDN URLs
if (images.length === 0 && response?.data) {
console.log("[Meta AI] Structured extraction failed, attempting recursive search...");
const foundUrls = this.recursiveSearchForImages(response.data);
for (const url of foundUrls) {
images.push({
url: url,
prompt: originalPrompt,
model: "meta"
});
}
}
if (images.length === 0) {
console.log("[Meta AI] Extraction failed. Response keys:", Object.keys(response || {}));
if (response?.data) console.log("[Meta AI] Data keys:", Object.keys(response.data));
}
return images;
}
/**
* Recursive search for image-like URLs in the JSON tree
*/
private recursiveSearchForImages(obj: any, found: Set<string> = new Set()): string[] {
if (!obj || typeof obj !== 'object') return [];
for (const key in obj) {
const val = obj[key];
if (typeof val === 'string') {
if ((val.includes('fbcdn.net') || val.includes('meta.ai')) &&
(val.includes('.jpg') || val.includes('.png') || val.includes('.webp') || val.includes('image_uri=') || val.includes('/imagine/'))) {
found.add(val);
}
} else if (typeof val === 'object') {
this.recursiveSearchForImages(val, found);
}
}
return Array.from(found);
}
/**
* Helper to extract images from a single message node
*/
private extractImagesFromMessage(messageData: any, originalPrompt: string): MetaImageResult[] {
const images: MetaImageResult[] = [];
if (!messageData) return images;
// Check for imagine_card (image generation response)
const imagineCard = messageData?.imagine_card;
if (imagineCard?.session?.media_sets) {
for (const mediaSet of imagineCard.session.media_sets) {
if (mediaSet?.imagine_media) {
for (const media of mediaSet.imagine_media) {
if (media?.uri) {
const url = media?.uri || media?.image_uri || media?.image?.uri;
if (url) {
images.push({
url: media.uri,
url: url,
prompt: originalPrompt,
model: "imagine"
model: "meta"
});
}
}
@ -262,15 +515,17 @@ export class MetaAIClient {
}
}
// Check for attachments
// Check for attachments (alternative path)
const attachments = messageData?.attachments;
if (attachments) {
for (const attachment of attachments) {
if (attachment?.media?.image_uri) {
const media = attachment?.media;
const url = media?.image_uri || media?.uri || media?.image?.uri;
if (url) {
images.push({
url: attachment.media.image_uri,
url: url,
prompt: originalPrompt,
model: "imagine"
model: "meta"
});
}
}
@ -283,78 +538,71 @@ export class MetaAIClient {
* Poll for image generation completion
*/
private async pollForImages(initialResponse: any, prompt: string): Promise<MetaImageResult[]> {
const maxAttempts = 30;
const pollInterval = 2000;
// Kadabra uses external_conversation_id for polling
const conversationId = initialResponse?.data?.useKadabraSendMessageMutation?.node?.external_conversation_id ||
initialResponse?.data?.node?.external_conversation_id;
// Get the fetch_id from initial response for polling
const fetchId = initialResponse?.data?.node?.id ||
initialResponse?.data?.xabraAIPreviewMessageSendMutation?.message?.id;
if (!fetchId) {
console.warn("[Meta AI] No fetch ID for polling, returning empty");
if (!conversationId) {
console.warn("[Meta AI] No conversation ID found for polling");
return [];
}
const maxAttempts = 30;
const pollInterval = 2000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`[Meta AI] Polling attempt ${attempt + 1}/${maxAttempts}...`);
await new Promise(resolve => setTimeout(resolve, pollInterval));
const variables = {
external_conversation_id: conversationId
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "KadabraPromptRootQuery",
variables: JSON.stringify(variables),
doc_id: "25290569913909283", // KadabraPromptRootQuery ID
...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
});
try {
// Query for the message status
const statusResponse = await this.queryMessageStatus(fetchId);
const images = this.extractImages(statusResponse, prompt);
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": this.cookies,
"Origin": META_AI_BASE,
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
},
body: body.toString()
});
const data = await response.json();
const images = this.extractImages(data, prompt);
if (images.length > 0) {
console.log(`[Meta AI] Got ${images.length} images!`);
console.log(`[Meta AI] Got ${images.length} image(s) after polling!`);
return images;
}
// Check if generation failed
const status = statusResponse?.data?.node?.imagine_card?.session?.status;
// Check for failure status
const status = data?.data?.kadabra_prompt?.status;
if (status === "FAILED" || status === "ERROR") {
throw new Error("Meta AI image generation failed");
console.error("[Meta AI] Generation failed during polling");
break;
}
} catch (e) {
console.error("[Meta AI] Poll error:", e);
if (attempt === maxAttempts - 1) throw e;
} catch (e: any) {
console.error("[Meta AI] Poll error:", e.message);
}
}
throw new Error("Meta AI: Image generation timed out");
return [];
}
/**
* Query message status for polling
*/
private async queryMessageStatus(messageId: string): Promise<any> {
const variables = {
id: messageId
};
const body = new URLSearchParams({
fb_api_caller_class: "RelayModern",
fb_api_req_friendly_name: "useAbraMessageQuery",
variables: JSON.stringify(variables),
doc_id: "7654946557897648",
...(this.session.lsd && { lsd: this.session.lsd }),
...(this.session.fb_dtsg && { fb_dtsg: this.session.fb_dtsg })
});
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": this.cookies,
"Origin": META_AI_BASE,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
...(this.session.accessToken && { "Authorization": `OAuth ${this.session.accessToken}` })
},
body: body.toString()
});
return response.json();
}
/**
* Download image from URL and convert to base64

View file

@ -60,6 +60,12 @@ interface AppState {
removeFromGallery: (id: number) => Promise<void>;
clearGallery: () => Promise<void>;
isGenerating: boolean;
setIsGenerating: (isGenerating: boolean) => void;
showCookieExpired: boolean;
setShowCookieExpired: (show: boolean) => void;
// Videos
videos: VideoItem[];
@ -76,14 +82,14 @@ interface AppState {
imageCount: number;
theme: 'light' | 'dark';
// Provider selection
provider: 'whisk' | 'grok' | 'meta';
provider: 'whisk' | 'meta';
// Whisk (Google)
whiskCookies: string;
// Grok (xAI)
grokApiKey: string;
grokCookies: string;
// Meta AI
useMetaFreeWrapper: boolean;
metaFreeWrapperUrl: string;
metaCookies: string;
facebookCookies: string;
};
setSettings: (s: Partial<AppState['settings']>) => void;
}
@ -148,10 +154,18 @@ export const useStore = create<AppState>()(
},
clearGallery: async () => {
await db.gallery.clear();
set({ gallery: [] });
// Also clear persistent videos and history if desired, or just gallery?
// Assuming "Clear All" in Gallery context implies clearing the visual workspace.
set({ gallery: [], videos: [] });
},
isGenerating: false,
setIsGenerating: (isGenerating) => set({ isGenerating }),
showCookieExpired: false,
setShowCookieExpired: (show) => set({ showCookieExpired: show }),
// Videos
videos: [],
addVideo: (video) => set((state) => ({ videos: [video, ...state.videos] })),
@ -164,15 +178,16 @@ export const useStore = create<AppState>()(
})),
settings: {
aspectRatio: '1:1',
aspectRatio: '9:16',
preciseMode: false,
imageCount: 4,
theme: 'dark',
provider: 'whisk',
whiskCookies: '',
grokApiKey: '',
grokCookies: '',
metaCookies: ''
useMetaFreeWrapper: true,
metaFreeWrapperUrl: 'http://localhost:8000',
metaCookies: '',
facebookCookies: ''
},
setSettings: (s) => set((state) => ({ settings: { ...state.settings, ...s } }))
}),
@ -180,9 +195,9 @@ export const useStore = create<AppState>()(
name: 'kv-pix-storage',
partialize: (state) => ({
settings: state.settings,
// gallery: state.gallery, // Don't persist gallery to localStorage
// gallery: state.gallery, // Don't persist gallery to localStorage (too large)
history: state.history,
videos: state.videos // Persist videos
// videos: state.videos // Don't persist videos to localStorage (too large)
}),
}
)

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",

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

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