apix/components/PromptLibrary.tsx
Khoa.vo bec553fd76
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
v3.2.0: Fix CORS - add nginx reverse proxy for production
- Add nginx to Dockerfile as reverse proxy
- Route /api/* to FastAPI, / to Next.js on single port 80
- Update all frontend components to use /api prefix in production
- Simplify docker-compose to single port 80
- Fixes CORS errors when deployed to remote servers
2026-01-13 08:11:28 +07:00

389 lines
18 KiB
TypeScript

"use client";
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { Copy, Sparkles, RefreshCw, Loader2, Image as ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Prompt, PromptCache } from '@/lib/types';
import { motion, AnimatePresence } from 'framer-motion';
// FastAPI backend URL - /api in production (nginx proxy), localhost in dev
const API_BASE = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? '/api' : 'http://localhost:8000');
export function PromptLibrary({ onSelect }: { onSelect?: (prompt: string) => void }) {
const { setPrompt, settings } = useStore();
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string>('All');
const [selectedSource, setSelectedSource] = useState<string>('All');
const [searchTerm, setSearchTerm] = useState('');
const [sortMode, setSortMode] = useState<'all' | 'latest' | 'history' | 'foryou'>('all');
const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const fetchPrompts = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/prompts`);
if (res.ok) {
const data: PromptCache = await res.json();
setPrompts(data.prompts);
} else {
throw new Error(`Server returned ${res.status}`);
}
} catch (error) {
console.error("Failed to fetch prompts", error);
setError("Unable to load the prompt library. Please check your connection.");
} finally {
setLoading(false);
}
};
const syncPrompts = async () => {
setLoading(true);
setError(null);
try {
const syncRes = await fetch(`${API_BASE}/prompts/sync`, { method: 'POST' });
if (!syncRes.ok) throw new Error('Sync failed');
await fetchPrompts();
} catch (error) {
console.error("Failed to sync prompts", error);
setError("Failed to sync new prompts from the community.");
} finally {
setLoading(false);
}
};
const generateMissingPreviews = async () => {
if (!settings.whiskCookies) {
alert("Please set Whisk Cookies in Settings first!");
return;
}
setGenerating(true);
try {
// Find prompts without images
const missing = prompts.filter(p => !p.images || p.images.length === 0);
console.log(`Found ${missing.length} prompts without images.`);
for (const prompt of missing) {
try {
console.log(`Requesting preview for: ${prompt.title}`);
const res = await fetch(`${API_BASE}/prompts/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: prompt.id,
prompt: prompt.prompt,
cookies: settings.whiskCookies
})
});
if (res.ok) {
const { url } = await res.json();
setPrompts(prev => prev.map(p =>
p.id === prompt.id ? { ...p, images: [url, ...(p.images || [])] } : p
));
} else {
const err = await res.json();
if (res.status === 422) {
console.warn(`Skipped unsafe prompt "${prompt.title}": ${err.error}`);
} else {
console.error('API Error:', err);
}
}
// Delay is still good to prevent flooding backend/google
await new Promise(r => setTimeout(r, 2000));
} catch (error) {
console.error(`Failed to generate for ${prompt.id}:`, error);
}
}
} catch (e) {
console.error("Generation process failed:", e);
} finally {
setGenerating(false);
}
};
useEffect(() => {
fetchPrompts();
}, []);
const handleSelect = async (p: Prompt) => {
setPrompt(p.prompt);
if (onSelect) onSelect(p.prompt);
// Track usage
try {
await fetch(`${API_BASE}/prompts/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id })
});
// Optimistic update
setPrompts(prev => prev.map(item =>
item.id === p.id
? { ...item, useCount: (item.useCount || 0) + 1, lastUsedAt: Date.now() }
: item
));
} catch (e) {
console.error("Failed to track usage", e);
}
};
// Derived State
const filteredPrompts = prompts.filter(p => {
const matchesSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.prompt.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'All' || p.category === selectedCategory;
const matchesSource = selectedSource === 'All' || p.source === selectedSource;
return matchesSearch && matchesCategory && matchesSource;
}).sort((a, b) => {
if (sortMode === 'latest') {
return (b.createdAt || 0) - (a.createdAt || 0);
}
if (sortMode === 'history') {
return (b.lastUsedAt || 0) - (a.lastUsedAt || 0);
}
return 0; // Default order (or ID)
});
const displayPrompts = () => {
if (sortMode === 'history') {
return filteredPrompts.filter(p => (p.useCount || 0) > 0);
}
if (sortMode === 'foryou') {
// Calculate top categories
const categoryCounts: Record<string, number> = {};
prompts.filter(p => (p.useCount || 0) > 0).forEach(p => {
categoryCounts[p.category] = (categoryCounts[p.category] || 0) + 1;
});
const topCategories = Object.entries(categoryCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
.map(([cat]) => cat);
if (topCategories.length === 0) return filteredPrompts; // No history yet
return filteredPrompts.filter(p => topCategories.includes(p.category));
}
return filteredPrompts;
};
const finalPrompts = displayPrompts();
const uniqueCategories = ['All', ...Array.from(new Set(prompts.map(p => p.category)))].filter(Boolean);
const uniqueSources = ['All', ...Array.from(new Set(prompts.map(p => p.source)))].filter(Boolean);
return (
<div className="max-w-6xl mx-auto p-4 md:p-8 space-y-10 pb-32">
<div className="flex flex-col items-center text-center gap-6">
<div className="flex flex-col items-center gap-3">
<div className="p-4 bg-primary/10 rounded-2xl text-primary shadow-sm">
<Sparkles className="h-8 w-8" />
</div>
<div>
<h2 className="text-3xl font-black tracking-tight">Prompt Library</h2>
<p className="text-muted-foreground text-sm font-medium">Curated inspiration from the community.</p>
</div>
</div>
<div className="flex flex-col items-center gap-4 w-full max-w-2xl">
<div className="relative flex-1 w-full group">
<input
type="text"
placeholder="Search prompts..."
className="px-5 py-3 pl-12 pr-28 rounded-2xl bg-card border border-border/50 focus:border-primary focus:ring-4 focus:ring-primary/10 focus:outline-none w-full transition-all shadow-soft"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
</div>
{/* Compact Action Buttons inside search bar */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 p-1 bg-muted/50 rounded-xl border border-border/30">
<button
onClick={generateMissingPreviews}
disabled={generating}
className={cn(
"p-1.5 hover:bg-card rounded-lg transition-all active:scale-90",
generating && "animate-pulse text-primary bg-card shadow-sm"
)}
title="Renew/Generate Previews"
>
<ImageIcon className="h-4 w-4" />
</button>
<div className="w-px h-4 bg-border/50 mx-0.5" />
<button
onClick={syncPrompts}
disabled={loading}
className="p-1.5 hover:bg-card rounded-lg transition-all active:scale-90"
title="Sync Library"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-6 rounded-3xl flex flex-col items-center gap-4 text-center max-w-md mx-auto">
<p className="font-bold">{error}</p>
<button
onClick={() => fetchPrompts()}
className="px-6 py-2 bg-destructive text-white rounded-full text-xs font-black uppercase tracking-widest hover:bg-red-600 transition-all active:scale-95"
>
Retry Now
</button>
</div>
)}
{generating && (
<div className="bg-primary/5 border border-primary/20 text-primary p-4 rounded-2xl flex items-center justify-center gap-3 animate-in fade-in slide-in-from-top-2 max-w-2xl mx-auto shadow-sm">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="font-bold text-xs uppercase tracking-wider">Generating library previews...</span>
</div>
)}
{/* Smart Tabs */}
<div className="flex justify-center">
<div className="flex items-center gap-1 bg-muted/50 p-1.5 rounded-2xl border border-border/50 shadow-soft">
{(['all', 'latest', 'history', 'foryou'] as const).map(mode => (
<button
key={mode}
onClick={() => setSortMode(mode)}
className={cn(
"px-6 py-2.5 rounded-xl text-xs font-black transition-all capitalize uppercase tracking-tighter active:scale-95",
sortMode === mode
? "bg-primary text-primary-foreground shadow-lg shadow-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
{mode === 'foryou' ? 'For You' : mode}
</button>
))}
</div>
</div>
{/* Sub-Categories */}
{sortMode === 'all' && (
<div className="flex flex-wrap gap-2 justify-center max-w-4xl mx-auto">
{uniqueCategories.map(cat => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={cn(
"px-5 py-2 rounded-2xl text-xs font-bold transition-all border active:scale-95",
selectedCategory === cat
? "bg-secondary text-secondary-foreground border-transparent shadow-md"
: "bg-card hover:bg-muted text-muted-foreground border-border/50"
)}
>
{cat}
</button>
))}
</div>
)}
{/* Source Filter */}
<div className="flex flex-wrap gap-3 items-center justify-center pt-2">
<span className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60 mr-1">Sources:</span>
{uniqueSources.map(source => (
<button
key={source}
onClick={() => setSelectedSource(source)}
className={cn(
"px-4 py-1.5 rounded-xl text-[10px] font-black tracking-widest uppercase transition-all border active:scale-95",
selectedSource === source
? "bg-primary/10 text-primary border-primary/20 shadow-sm"
: "bg-muted/30 hover:bg-muted text-muted-foreground/70 border-border/30"
)}
>
{source}
</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="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>
<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>
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && finalPrompts.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
{sortMode === 'history' ? "No prompts used yet." : "No prompts found."}
</div>
)}
</div>
);
}