- 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
389 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|