Files
datingsim-visual-editor/components/VNOutline.tsx
Stanislaw Dzioba 86edc3bf88 add choice mechanic
2026-03-23 19:44:41 +01:00

202 lines
8.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};