436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Sparkles, Copy, Check, X, Lock, Unlock, Play, RefreshCw, Loader2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Textarea } from '@/components/ui/Input';
|
|
import type { Personaje, CharacterAsset, CharacterAssetsData } from '@/types/project';
|
|
|
|
interface CharacterAssetsPanelProps {
|
|
personaje: Personaje;
|
|
slug: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const API_BASE = (slug: string, id: string) =>
|
|
`/api/projects/${slug}/personajes/${id}/assets`;
|
|
|
|
export function CharacterAssetsPanel({ personaje, slug, onClose }: CharacterAssetsPanelProps) {
|
|
const [data, setData] = useState<CharacterAssetsData>({ assets: [], baseLocked: false });
|
|
const [loading, setLoading] = useState<string | null>(null);
|
|
const [fetching, setFetching] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
const [basePromptEdit, setBasePromptEdit] = useState('');
|
|
const [imgVersion, setImgVersion] = useState(0);
|
|
const abortRef = useRef(false);
|
|
|
|
const url = API_BASE(slug, personaje.id);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setFetching(true);
|
|
try {
|
|
const res = await fetch(url);
|
|
if (res.ok) {
|
|
const d = await res.json();
|
|
setData(d);
|
|
setImgVersion(v => v + 1);
|
|
const base = d.assets?.find((a: CharacterAsset) => a.isBase);
|
|
if (base) setBasePromptEdit(base.prompt || '');
|
|
}
|
|
} catch (e) {
|
|
console.error('Error fetching assets:', e);
|
|
} finally {
|
|
setFetching(false);
|
|
}
|
|
}, [url]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
// Cleanup: abort batch generation on unmount
|
|
useEffect(() => {
|
|
return () => { abortRef.current = true; };
|
|
}, []);
|
|
|
|
const postAction = async (action: string, extra?: Record<string, any>): Promise<CharacterAssetsData | null> => {
|
|
const loadingKey = extra?.assetId || action;
|
|
setLoading(loadingKey);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action, ...extra }),
|
|
});
|
|
const d = await res.json();
|
|
if (!res.ok) { setError(d.error || `Error ${res.status}`); return null; }
|
|
setData(d);
|
|
setImgVersion(v => v + 1);
|
|
const base = d.assets?.find((a: CharacterAsset) => a.isBase);
|
|
if (base) setBasePromptEdit(base.prompt || '');
|
|
return d;
|
|
} catch (e: any) {
|
|
setError(e.message || 'Error de conexión');
|
|
return null;
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
const generateAllVariations = async () => {
|
|
const pending = data.assets.filter(a => !a.isBase && a.status !== 'generated');
|
|
if (pending.length === 0) return;
|
|
abortRef.current = false;
|
|
|
|
for (const asset of pending) {
|
|
if (abortRef.current) break;
|
|
const result = await postAction('generate-variation', { assetId: asset.id });
|
|
if (!result) break;
|
|
}
|
|
};
|
|
|
|
const copyPrompt = (asset: CharacterAsset) => {
|
|
navigator.clipboard.writeText(asset.prompt);
|
|
setCopiedId(asset.id);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
};
|
|
|
|
const { assets, baseLocked } = data;
|
|
const baseAsset = assets.find(a => a.isBase);
|
|
const otherAssets = assets.filter(a => !a.isBase);
|
|
const tposeOthers = otherAssets.filter(a => a.type === 'tpose');
|
|
const emotionAssets = otherAssets.filter(a => a.type === 'emotion');
|
|
const generatedCount = assets.filter(a => a.status === 'generated').length;
|
|
|
|
// Determine current workflow step
|
|
const step = assets.length === 0 ? 0
|
|
: !baseAsset?.imageUrl ? 1
|
|
: !baseLocked ? 2
|
|
: 3;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg shadow-xl shadow-black/10 max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-stone-100">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-base font-semibold text-stone-900">{personaje.nombre}</h2>
|
|
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider">Character Assets</span>
|
|
{assets.length > 0 && (
|
|
<span className="text-[10px] text-stone-400">{generatedCount}/{assets.length} generados</span>
|
|
)}
|
|
</div>
|
|
<button onClick={onClose} className="p-1 text-stone-400 hover:text-stone-600 transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stepper */}
|
|
{assets.length > 0 && (
|
|
<div className="px-5 py-2 bg-stone-50/50 border-b border-stone-100 flex items-center gap-6 text-[11px]">
|
|
{['Prompts', 'Imagen base', 'Fijar base', 'Variaciones'].map((label, i) => (
|
|
<div key={label} className={`flex items-center gap-1.5 ${
|
|
i === step ? 'text-indigo-600 font-semibold' : i < step ? 'text-emerald-600' : 'text-stone-300'
|
|
}`}>
|
|
<span className={`w-4 h-4 rounded-full text-[10px] flex items-center justify-center font-bold ${
|
|
i < step ? 'bg-emerald-100 text-emerald-600' : i === step ? 'bg-indigo-100 text-indigo-600' : 'bg-stone-100 text-stone-400'
|
|
}`}>{i < step ? '✓' : i + 1}</span>
|
|
{label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mx-5 mt-3 text-xs text-red-600 bg-red-50 px-3 py-2 rounded">{error}</div>
|
|
)}
|
|
|
|
<div className="p-5 overflow-y-auto max-h-[calc(90vh-160px)]">
|
|
{fetching ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin w-5 h-5 border-2 border-indigo-600 border-t-transparent rounded-full" />
|
|
</div>
|
|
|
|
/* Step 0: No prompts yet */
|
|
) : step === 0 ? (
|
|
<div className="text-center py-12">
|
|
<p className="text-stone-500 text-sm mb-1">Sin prompts de assets.</p>
|
|
<p className="text-stone-400 text-xs mb-5">
|
|
Genera prompts para T-pose y emociones usando IA.
|
|
</p>
|
|
<Button onClick={() => postAction('generate-prompts')} loading={loading === 'generate-prompts'}>
|
|
<Sparkles className="w-4 h-4" />
|
|
Generar prompts de assets
|
|
</Button>
|
|
</div>
|
|
|
|
/* Steps 1-3: Has prompts */
|
|
) : (
|
|
<div className="space-y-6">
|
|
|
|
{/* BASE IMAGE SECTION */}
|
|
<div className={`border rounded-lg p-4 ${
|
|
baseLocked ? 'border-emerald-200 bg-emerald-50/30' : 'border-indigo-200 bg-indigo-50/30'
|
|
}`}>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
{baseLocked ? <Lock className="w-3.5 h-3.5 text-emerald-500" /> : <Unlock className="w-3.5 h-3.5 text-indigo-500" />}
|
|
<span className="text-xs font-semibold text-stone-700">Imagen Base (Referencia Canónica)</span>
|
|
{baseLocked && <span className="text-[10px] text-emerald-600 font-medium">Fijada</span>}
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
{/* Base image preview */}
|
|
<div className="w-48 flex-shrink-0">
|
|
{baseAsset?.imageUrl ? (
|
|
<div className="aspect-square bg-white rounded-lg border border-stone-200 overflow-hidden relative group/base">
|
|
<img src={`${baseAsset.imageUrl}?v=${imgVersion}`} alt="Base" className="w-full h-full object-cover" />
|
|
{loading === 'generate-base-image' && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
|
<Loader2 className="w-5 h-5 text-white animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : loading === 'generate-base-image' ? (
|
|
<div className="aspect-square bg-white rounded-lg border border-stone-200 flex items-center justify-center">
|
|
<Loader2 className="w-5 h-5 text-indigo-400 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="aspect-square bg-white rounded-lg border border-stone-200 flex items-center justify-center">
|
|
<span className="text-xs text-stone-300">Sin imagen</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex-1 space-y-3">
|
|
{!baseLocked && (
|
|
<>
|
|
<div>
|
|
<label className="text-[11px] font-medium text-stone-500 mb-1 block">Prompt (editable)</label>
|
|
<Textarea
|
|
value={basePromptEdit}
|
|
onChange={(e) => setBasePromptEdit(e.target.value)}
|
|
rows={4}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => postAction('generate-base-image', { prompt: basePromptEdit })}
|
|
loading={loading === 'generate-base-image'}
|
|
>
|
|
{baseAsset?.imageUrl ? <RefreshCw className="w-3.5 h-3.5" /> : <Sparkles className="w-3.5 h-3.5" />}
|
|
{baseAsset?.imageUrl ? 'Regenerar' : 'Generar imagen base'}
|
|
</Button>
|
|
{baseAsset?.imageUrl && baseAsset.status === 'generated' && (
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => postAction('lock-base')}
|
|
loading={loading === 'lock-base'}
|
|
>
|
|
<Lock className="w-3.5 h-3.5" />
|
|
Fijar como referencia
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{baseLocked && (
|
|
<p className="text-xs text-stone-500">
|
|
Imagen fijada como referencia. Las variaciones se generarán a partir de esta imagen.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* VARIATIONS SECTION */}
|
|
{baseLocked && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-stone-700">Variaciones</span>
|
|
<Button
|
|
size="sm"
|
|
onClick={generateAllVariations}
|
|
loading={!!loading}
|
|
disabled={otherAssets.every(a => a.status === 'generated')}
|
|
>
|
|
<Play className="w-3.5 h-3.5" />
|
|
Generar todas las variaciones
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Other T-Poses */}
|
|
{tposeOthers.length > 0 && (
|
|
<div>
|
|
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider block mb-2">T-Pose</span>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{tposeOthers.map(asset => (
|
|
<AssetCard
|
|
key={asset.id}
|
|
asset={asset}
|
|
imgVersion={imgVersion}
|
|
copiedId={copiedId}
|
|
onCopy={copyPrompt}
|
|
onGenerate={() => postAction('generate-variation', { assetId: asset.id })}
|
|
generating={loading === asset.id}
|
|
canGenerate={baseLocked && !loading}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Emotions */}
|
|
{emotionAssets.length > 0 && (
|
|
<div>
|
|
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider block mb-2">Emociones</span>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{emotionAssets.map(asset => (
|
|
<AssetCard
|
|
key={asset.id}
|
|
asset={asset}
|
|
imgVersion={imgVersion}
|
|
copiedId={copiedId}
|
|
onCopy={copyPrompt}
|
|
onGenerate={() => postAction('generate-variation', { assetId: asset.id })}
|
|
generating={loading === asset.id}
|
|
canGenerate={baseLocked && !loading}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Pre-lock: show all assets as prompt list */}
|
|
{!baseLocked && assets.length > 1 && (
|
|
<div>
|
|
<span className="text-[11px] font-medium text-stone-400 uppercase tracking-wider block mb-2">
|
|
Prompts pendientes ({otherAssets.length} variaciones)
|
|
</span>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{otherAssets.map(asset => (
|
|
<AssetCard
|
|
key={asset.id}
|
|
asset={asset}
|
|
imgVersion={imgVersion}
|
|
copiedId={copiedId}
|
|
onCopy={copyPrompt}
|
|
canGenerate={false}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Regenerate prompts */}
|
|
<div className="pt-3 border-t border-stone-100 flex justify-end">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => postAction('generate-prompts')}
|
|
loading={loading === 'generate-prompts'}
|
|
>
|
|
<RefreshCw className="w-3 h-3" />
|
|
Regenerar todos los prompts
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssetCard({
|
|
asset,
|
|
imgVersion,
|
|
copiedId,
|
|
onCopy,
|
|
onGenerate,
|
|
generating,
|
|
canGenerate,
|
|
}: {
|
|
asset: CharacterAsset;
|
|
imgVersion: number;
|
|
copiedId: string | null;
|
|
onCopy: (asset: CharacterAsset) => void;
|
|
onGenerate?: () => void;
|
|
generating?: boolean;
|
|
canGenerate?: boolean;
|
|
}) {
|
|
const isCopied = copiedId === asset.id;
|
|
|
|
const statusColors: Record<string, string> = {
|
|
pending: 'bg-stone-300',
|
|
prompt_ready: 'bg-amber-400',
|
|
generating: 'bg-blue-400 animate-pulse',
|
|
generated: 'bg-emerald-400',
|
|
};
|
|
|
|
return (
|
|
<div className="border border-stone-200 rounded-lg p-2.5 group">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className={`w-1.5 h-1.5 rounded-full ${statusColors[asset.status] || 'bg-stone-300'}`} />
|
|
<span className="text-[11px] font-medium text-stone-700">{asset.label}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{asset.imageUrl ? (
|
|
<div className="aspect-square bg-stone-50 rounded border border-stone-100 mb-1.5 overflow-hidden relative group/img">
|
|
<img src={`${asset.imageUrl}?v=${imgVersion}`} alt={asset.label} className="w-full h-full object-cover" />
|
|
{generating ? (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
|
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
|
</div>
|
|
) : canGenerate && onGenerate ? (
|
|
<button
|
|
onClick={onGenerate}
|
|
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity"
|
|
>
|
|
<RefreshCw className="w-4 h-4 text-white" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : generating ? (
|
|
<div className="aspect-square bg-stone-50 rounded border border-stone-100 mb-1.5 flex items-center justify-center">
|
|
<Loader2 className="w-4 h-4 text-indigo-400 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="aspect-square bg-stone-50 rounded border border-stone-100 mb-1.5 flex flex-col items-center justify-center gap-1">
|
|
{canGenerate && onGenerate ? (
|
|
<button
|
|
onClick={onGenerate}
|
|
className="text-[10px] text-indigo-500 hover:text-indigo-700 font-medium"
|
|
>
|
|
Generar
|
|
</button>
|
|
) : (
|
|
<span className="text-[10px] text-stone-300">Pendiente</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{asset.prompt && (
|
|
<div className="flex gap-1">
|
|
<p className="text-[9px] text-stone-400 leading-tight flex-1 line-clamp-2">{asset.prompt}</p>
|
|
<button
|
|
onClick={() => onCopy(asset)}
|
|
className="flex-shrink-0 p-0.5 text-stone-300 hover:text-indigo-500 transition-colors"
|
|
title="Copiar prompt"
|
|
>
|
|
{isCopied ? <Check className="w-2.5 h-2.5 text-emerald-500" /> : <Copy className="w-2.5 h-2.5" />}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|