apix/components/PromptLibrary.tsx
Khoa.vo 8741e3b89f
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
feat: Initial commit with multi-provider image generation
2026-01-05 13:50:35 +07:00

351 lines
16 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';
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 fetchPrompts = async () => {
setLoading(true);
try {
const res = await fetch('/api/prompts');
if (res.ok) {
const data: PromptCache = await res.json();
setPrompts(data.prompts);
}
} catch (error) {
console.error("Failed to fetch prompts", error);
} finally {
setLoading(false);
}
};
const syncPrompts = async () => {
setLoading(true);
try {
const syncRes = await fetch('/api/prompts/sync', { method: 'POST' });
if (!syncRes.ok) throw new Error('Sync failed');
await fetchPrompts();
} catch (error) {
console.error("Failed to sync prompts", error);
} 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/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/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-8 pb-32">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-primary/10 rounded-xl text-primary">
<Sparkles className="h-6 w-6" />
</div>
<div>
<h2 className="text-2xl font-bold">Prompt Library</h2>
<p className="text-muted-foreground">Curated inspiration from the community.</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={generateMissingPreviews}
disabled={generating}
className={cn(
"p-2 hover:bg-secondary rounded-full transition-colors",
generating && "animate-pulse text-yellow-500"
)}
title="Auto-Generate Missing Previews"
>
<ImageIcon className="h-5 w-5" />
</button>
<button
onClick={syncPrompts}
disabled={loading}
className="p-2 hover:bg-secondary rounded-full transition-colors"
title="Sync from GitHub"
>
<RefreshCw className={cn("h-5 w-5", loading && "animate-spin")} />
</button>
<input
type="text"
placeholder="Search prompts..."
className="px-4 py-2 rounded-lg bg-card border focus:border-primary focus:outline-none w-full md:w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{generating && (
<div className="bg-primary/10 border border-primary/20 text-primary p-4 rounded-xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="font-medium">Generating preview images for library prompts... This may take a while.</span>
</div>
)}
{/* Smart Tabs */}
<div className="flex items-center gap-1 bg-secondary/30 p-1 rounded-xl w-fit">
{(['all', 'latest', 'history', 'foryou'] as const).map(mode => (
<button
key={mode}
onClick={() => setSortMode(mode)}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize",
sortMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
{mode === 'foryou' ? 'For You' : mode}
</button>
))}
</div>
{/* Sub-Categories (only show if NOT history/foryou to keep clean? Or keep it?) */}
{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>
)}
{/* Source Filter */}
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm font-medium text-muted-foreground mr-2">Sources:</span>
{uniqueSources.map(source => (
<button
key={source}
onClick={() => setSelectedSource(source)}
className={cn(
"px-3 py-1 rounded-full text-xs font-medium transition-colors border",
selectedSource === source
? "bg-primary text-primary-foreground border-primary"
: "bg-card hover:bg-secondary text-muted-foreground border-secondary"
)}
>
{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>
);
}