202 lines
8.4 KiB
TypeScript
202 lines
8.4 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
||
import { Statement } from '@/lib/vn-parser';
|
||
import { Sprite } from '@/lib/sprite-bank';
|
||
|
||
interface VNOutlineProps {
|
||
statements: Statement[];
|
||
currentSay: Statement | null;
|
||
onSelectLine: (line: number) => void;
|
||
isParsing: boolean;
|
||
spriteBank: Record<string, Sprite[]>;
|
||
onUpdateSpriteBank: (newBank: Record<string, Sprite[]>) => void;
|
||
onAddSpriteToScript: (char: string, id: string, url: string) => void;
|
||
}
|
||
|
||
export const VNOutline: React.FC<VNOutlineProps> = ({
|
||
statements,
|
||
currentSay,
|
||
onSelectLine,
|
||
isParsing,
|
||
spriteBank,
|
||
onUpdateSpriteBank,
|
||
onAddSpriteToScript
|
||
}) => {
|
||
const [activeTab, setActiveTab] = useState<'outline' | 'sprites'>('outline');
|
||
const [newCharName, setNewCharName] = useState('');
|
||
const [uploadingFor, setUploadingFor] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const addCharacter = () => {
|
||
if (!newCharName || spriteBank[newCharName]) return;
|
||
onUpdateSpriteBank({ ...spriteBank, [newCharName]: [] });
|
||
setNewCharName('');
|
||
};
|
||
|
||
const removeCharacter = (charName: string) => {
|
||
const { [charName]: _, ...rest } = spriteBank;
|
||
onUpdateSpriteBank(rest);
|
||
};
|
||
|
||
const addSpriteUrl = (charName: string) => {
|
||
const id = prompt('Sprite ID (e.g. happy):');
|
||
if (!id) return;
|
||
const url = prompt('Sprite Image URL:');
|
||
if (!url) return;
|
||
|
||
onAddSpriteToScript(charName, id, url);
|
||
};
|
||
|
||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file || !uploadingFor) return;
|
||
|
||
const id = prompt('Sprite ID (e.g. happy):');
|
||
if (!id) return;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const url = e.target?.result as string;
|
||
if (url) {
|
||
onAddSpriteToScript(uploadingFor, id, url);
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
|
||
// Reset file input
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
setUploadingFor(null);
|
||
};
|
||
|
||
const triggerFileUpload = (charName: string) => {
|
||
setUploadingFor(charName);
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
const removeSprite = (charName: string, spriteId: string) => {
|
||
const newSprites = spriteBank[charName].filter(s => s.id !== spriteId);
|
||
onUpdateSpriteBank({ ...spriteBank, [charName]: newSprites });
|
||
};
|
||
|
||
return (
|
||
<div className="w-72 border-r border-gray-800 flex flex-col bg-[#0b0e14]">
|
||
{/* Tabs */}
|
||
<div className="flex border-b border-gray-800">
|
||
<button
|
||
onClick={() => setActiveTab('outline')}
|
||
className={`flex-1 py-3 text-[10px] font-bold uppercase tracking-widest transition-colors ${activeTab === 'outline' ? 'text-cyan-400 bg-gray-800/50 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-300'}`}
|
||
>
|
||
Outline
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('sprites')}
|
||
className={`flex-1 py-3 text-[10px] font-bold uppercase tracking-widest transition-colors ${activeTab === 'sprites' ? 'text-cyan-400 bg-gray-800/50 border-b-2 border-cyan-400' : 'text-gray-500 hover:text-gray-300'}`}
|
||
>
|
||
Sprite Bank
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||
{activeTab === 'outline' ? (
|
||
<div className="p-2 space-y-0.5">
|
||
{statements.map((s, idx) => {
|
||
if (s.type === 'comment') return null;
|
||
const isSay = s.type === 'say';
|
||
const isActive = isSay && currentSay?.lineNumber === s.lineNumber;
|
||
|
||
return (
|
||
<button
|
||
key={idx}
|
||
onClick={() => onSelectLine(s.lineNumber)}
|
||
className={`w-full text-left text-[11px] p-2 rounded transition-all group ${
|
||
isActive ? 'bg-cyan-950/30 border-l-2 border-cyan-400' : 'hover:bg-gray-800/50 border-l-2 border-transparent'
|
||
}`}
|
||
>
|
||
<div className="flex justify-between items-start mb-0.5">
|
||
<span className={`font-bold uppercase text-[9px] tracking-tighter ${
|
||
isActive ? 'text-cyan-400' : 'text-gray-500'
|
||
}`}>
|
||
{s.type}
|
||
</span>
|
||
<span className="text-[9px] text-gray-700 font-mono">L{s.lineNumber}</span>
|
||
</div>
|
||
<div className={`truncate ${isActive ? 'text-cyan-100' : 'text-gray-400 group-hover:text-gray-200'}`}>
|
||
{s.type === 'say' && (
|
||
<span className="font-semibold text-cyan-300/80 mr-1">
|
||
{s.character || 'Narrator'}:
|
||
</span>
|
||
)}
|
||
{s.type === 'say' && (s.segments?.[0]?.text || s.continuations?.[0]?.text || "...")}
|
||
{s.type === 'wait' && `${s.duration} ${s.skippable ? '(skippable)' : ''}`}
|
||
{s.type === 'sprite' && `${s.character} (${s.emotion}) at ${s.value}`}
|
||
{s.type === 'set' && `${s.variable} = ${s.value}`}
|
||
{s.type === 'fire' && `${s.event} ${s.args?.join(' ') || ''}`}
|
||
{s.type === 'nextfile' && `Next: ${s.filename}`}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="p-4 space-y-8">
|
||
{/* Add Character UI */}
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={newCharName}
|
||
onChange={e => setNewCharName(e.target.value)}
|
||
placeholder="Character Name..."
|
||
className="flex-1 bg-gray-900 border border-gray-800 rounded px-2 py-1 text-xs outline-none focus:border-cyan-500"
|
||
/>
|
||
<button
|
||
onClick={addCharacter}
|
||
className="bg-cyan-600 hover:bg-cyan-500 text-white px-3 py-1 rounded text-[10px] font-bold"
|
||
>
|
||
ADD
|
||
</button>
|
||
</div>
|
||
|
||
{Object.entries(spriteBank).map(([charName, sprites]) => (
|
||
<div key={charName} className="space-y-3">
|
||
<div className="flex justify-between items-center border-b border-cyan-900/50 pb-1">
|
||
<h3 className="text-[10px] font-bold uppercase tracking-widest text-cyan-500">
|
||
{charName}
|
||
</h3>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => addSpriteUrl(charName)} className="text-[8px] text-gray-400 hover:text-cyan-400 uppercase font-bold tracking-tighter">URL</button>
|
||
<button onClick={() => triggerFileUpload(charName)} className="text-[8px] text-gray-400 hover:text-cyan-400 uppercase font-bold tracking-tighter">Upload</button>
|
||
<button onClick={() => removeCharacter(charName)} className="text-[8px] text-gray-400 hover:text-red-400 uppercase font-bold tracking-tighter">Delete</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{sprites.map(sprite => (
|
||
<div key={sprite.id} className="group relative">
|
||
<div className="aspect-[2/3] bg-gray-900 rounded border border-gray-800 overflow-hidden p-1">
|
||
<img src={sprite.url} alt={sprite.name} className="w-full h-full object-contain opacity-60 group-hover:opacity-100 transition-opacity" />
|
||
</div>
|
||
<div className="absolute bottom-0 inset-x-0 bg-black/80 p-1 text-[8px] font-bold flex justify-between items-center group-hover:bg-cyan-950/90">
|
||
<span className="truncate flex-1">{sprite.id}</span>
|
||
<button
|
||
onClick={() => removeSprite(charName, sprite.id)}
|
||
className="text-red-500 hover:text-red-400 ml-1 opacity-0 group-hover:opacity-100"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
className="hidden"
|
||
accept="image/*"
|
||
onChange={handleFileUpload}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|