Files
story-studio/src/components/project/CharacterAssetsPanel.tsx
2026-02-21 10:31:46 +01:00

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>
);
}