diff --git a/editor/backend/pom.xml b/editor/backend/pom.xml index ba50b3c..4be68d9 100644 --- a/editor/backend/pom.xml +++ b/editor/backend/pom.xml @@ -13,7 +13,7 @@ com.engine editor-backend - 0.3.0-RELEASE + 0.4.0-RELEASE editor-backend Arvexis - Editor Backend diff --git a/editor/backend/src/main/resources/bundled/runtime.jar b/editor/backend/src/main/resources/bundled/runtime.jar index bb61c50..432af5a 100644 Binary files a/editor/backend/src/main/resources/bundled/runtime.jar and b/editor/backend/src/main/resources/bundled/runtime.jar differ diff --git a/editor/frontend/src/api/localization.ts b/editor/frontend/src/api/localization.ts new file mode 100644 index 0000000..008826f --- /dev/null +++ b/editor/frontend/src/api/localization.ts @@ -0,0 +1,65 @@ +import apiClient from './client' +import type { Locale, SubtitleEntry, DecisionTranslation } from '@/types' + +// ── Locales ────────────────────────────────────────────────────────────────── + +export function listLocales(): Promise { + return apiClient.get('/locales') +} + +export function addLocale(code: string, name: string): Promise { + return apiClient.post('/locales', { code, name }) +} + +export function deleteLocale(code: string): Promise<{ deleted: string }> { + return apiClient.delete(`/locales/${encodeURIComponent(code)}`) +} + +// ── Subtitles ──────────────────────────────────────────────────────────────── + +export function getSubtitles(params?: { sceneId?: string; locale?: string }): Promise { + const q = new URLSearchParams() + if (params?.sceneId) q.set('sceneId', params.sceneId) + if (params?.locale) q.set('locale', params.locale) + const qs = q.toString() + return apiClient.get(`/subtitles${qs ? '?' + qs : ''}`) +} + +export function upsertSubtitle(entry: { + id?: string + sceneId: string + localeCode: string + startTime: number + endTime: number + text: string +}): Promise { + return apiClient.post('/subtitles', entry) +} + +export function deleteSubtitle(id: string): Promise<{ deleted: string }> { + return apiClient.delete(`/subtitles/${encodeURIComponent(id)}`) +} + +// ── Decision Translations ──────────────────────────────────────────────────── + +export function getDecisionTranslations(params?: { sceneId?: string; locale?: string }): Promise { + const q = new URLSearchParams() + if (params?.sceneId) q.set('sceneId', params.sceneId) + if (params?.locale) q.set('locale', params.locale) + const qs = q.toString() + return apiClient.get(`/decision-translations${qs ? '?' + qs : ''}`) +} + +export function upsertDecisionTranslation(entry: { + id?: string + decisionKey: string + sceneId: string + localeCode: string + label: string +}): Promise { + return apiClient.post('/decision-translations', entry) +} + +export function deleteDecisionTranslation(id: string): Promise<{ deleted: string }> { + return apiClient.delete(`/decision-translations/${encodeURIComponent(id)}`) +} diff --git a/editor/frontend/src/components/editor/LocalizationPanel.tsx b/editor/frontend/src/components/editor/LocalizationPanel.tsx index 23b2326..835f1ff 100644 --- a/editor/frontend/src/components/editor/LocalizationPanel.tsx +++ b/editor/frontend/src/components/editor/LocalizationPanel.tsx @@ -1,124 +1,567 @@ -import { useState } from 'react' -import type { Locale } from '@/types' +import { useState, useEffect, useCallback, type ReactNode } from 'react' +import type { Locale, SubtitleEntry, DecisionTranslation, GraphNode } from '@/types' +import { + listLocales, addLocale, deleteLocale, + getSubtitles, upsertSubtitle, deleteSubtitle, + getDecisionTranslations, upsertDecisionTranslation, deleteDecisionTranslation, +} from '@/api/localization' +import { listNodes } from '@/api/graph' +import { useEditorStore } from '@/store' -const PLACEHOLDER_LOCALES: Locale[] = [ - { code: 'en', name: 'English' }, - { code: 'fr', name: 'French' }, - { code: 'de', name: 'German' }, -] +type Tab = 'subtitles' | 'decisions' export default function LocalizationPanel() { - const [locales] = useState(PLACEHOLDER_LOCALES) - const [activeLocale, setActiveLocale] = useState(PLACEHOLDER_LOCALES[0].code) - const [newCode, setNewCode] = useState('') - const [newName, setNewName] = useState('') - const [showAdd, setShowAdd] = useState(false) - - function handleAddLocale() { - if (!newCode.trim() || !newName.trim()) return - // TODO: wire to backend when locale API is available - setNewCode('') - setNewName('') - setShowAdd(false) + const togglePanel = useEditorStore(s => s.toggleLocalizationPanel) + + const [locales, setLocales] = useState([]) + const [activeLocale, setActiveLocale] = useState('') + const [newCode, setNewCode] = useState('') + const [newName, setNewName] = useState('') + const [showAdd, setShowAdd] = useState(false) + const [scenes, setScenes] = useState([]) + const [selectedScene, setSelectedScene] = useState('') + const [tab, setTab] = useState('subtitles') + const [error, setError] = useState(null) + + // Subtitle state + const [subs, setSubs] = useState([]) + const [subsLoading, setSubsLoading] = useState(false) + const [newSubStart, setNewSubStart] = useState('') + const [newSubEnd, setNewSubEnd] = useState('') + const [newSubText, setNewSubText] = useState('') + + // Decision translation state + const [translations, setTranslations] = useState([]) + const [transLoading, setTransLoading] = useState(false) + const [sceneDecisionKeys, setSceneDecisionKeys] = useState([]) + + // ── Load locales + scenes on mount ────────────────────────────────────── + + useEffect(() => { + Promise.all([listLocales(), listNodes()]) + .then(([locs, nodes]) => { + setLocales(locs) + if (locs.length > 0) setActiveLocale(prev => prev || locs[0].code) + const sceneNodes = nodes.filter(n => n.type === 'scene') + setScenes(sceneNodes) + if (sceneNodes.length > 0) setSelectedScene(prev => prev || sceneNodes[0].id) + }) + .catch(e => setError(e instanceof Error ? e.message : 'Load failed')) + }, []) + + // ── Load subtitles when scene/locale changes ─────────────────────────── + + const loadSubs = useCallback(async () => { + if (!selectedScene || !activeLocale) { setSubs([]); return } + setSubsLoading(true) + try { + const data = await getSubtitles({ sceneId: selectedScene, locale: activeLocale }) + setSubs(data) + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load subtitles') } + finally { setSubsLoading(false) } + }, [selectedScene, activeLocale]) + + useEffect(() => { if (tab === 'subtitles') loadSubs() }, [loadSubs, tab]) + + // ── Load decision translations when scene/locale changes ─────────────── + + const loadTranslations = useCallback(async () => { + if (!selectedScene || !activeLocale) { setTranslations([]); return } + setTransLoading(true) + try { + const data = await getDecisionTranslations({ sceneId: selectedScene, locale: activeLocale }) + setTranslations(data) + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load translations') } + finally { setTransLoading(false) } + }, [selectedScene, activeLocale]) + + useEffect(() => { if (tab === 'decisions') loadTranslations() }, [loadTranslations, tab]) + + // ── Compute decision keys for the selected scene ─────────────────────── + + useEffect(() => { + const scene = scenes.find(s => s.id === selectedScene) + if (scene) { + setSceneDecisionKeys(scene.exits.filter(e => e.key !== 'CONTINUE').map(e => e.key)) + } else { + setSceneDecisionKeys([]) + } + }, [selectedScene, scenes]) + + // ── Locale CRUD ───────────────────────────────────────────────────────── + + async function handleAddLocale() { + const code = newCode.trim().toLowerCase() + const name = newName.trim() + if (!code || !name) { + setError('Both locale code and name are required') + return + } + setError(null) + try { + const loc = await addLocale(code, name) + setLocales(prev => [...prev.filter(l => l.code !== loc.code), loc]) + if (!activeLocale) setActiveLocale(loc.code) + setNewCode('') + setNewName('') + setShowAdd(false) + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to add locale') } + } + + async function handleDeleteLocale(code: string) { + try { + await deleteLocale(code) + setLocales(prev => { + const remaining = prev.filter(l => l.code !== code) + if (activeLocale === code) { + setActiveLocale(remaining.length > 0 ? remaining[0].code : '') + } + return remaining + }) + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to delete locale') } + } + + // ── Subtitle CRUD ─────────────────────────────────────────────────────── + + async function handleAddSubtitle() { + const start = parseFloat(newSubStart) + const end = parseFloat(newSubEnd) + const text = newSubText.trim() + if (isNaN(start) || isNaN(end) || !text || !selectedScene || !activeLocale) return + try { + await upsertSubtitle({ sceneId: selectedScene, localeCode: activeLocale, startTime: start, endTime: end, text }) + setNewSubStart('') + setNewSubEnd('') + setNewSubText('') + loadSubs() + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to add subtitle') } + } + + async function handleUpdateSubtitle(entry: SubtitleEntry) { + try { + await upsertSubtitle(entry) + loadSubs() + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to update subtitle') } + } + + async function handleDeleteSubtitle(id: string) { + try { + await deleteSubtitle(id) + setSubs(prev => prev.filter(s => s.id !== id)) + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to delete subtitle') } + } + + // ── Decision translation CRUD ────────────────────────────────────────── + + async function handleUpsertTranslation(decisionKey: string, label: string, existingId?: string) { + if (!label.trim() || !selectedScene || !activeLocale) return + try { + await upsertDecisionTranslation({ + id: existingId, + decisionKey, sceneId: selectedScene, localeCode: activeLocale, label: label.trim(), + }) + loadTranslations() + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to save translation') } + } + + async function handleDeleteTranslation(id: string) { + try { + await deleteDecisionTranslation(id) + setTranslations(prev => prev.filter(t => t.id !== id)) + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to delete translation') } } return ( -
+
{/* Header */} -
- - - - - Localization - Data model preview +
+
+ + Localization + +

Subtitles & Translations

+
+
- {/* Locale selector */} -
-
- Locales - + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Scrollable body */} +
+
+ + {/* ── Locales section ── */} + +
+ {locales.length === 0 && !showAdd && ( +

No locales defined yet.

+ )} + {locales.length > 0 && ( +
+ {locales.map(l => ( +
+ + +
+ ))} +
+ )} - {showAdd && ( -
-
- setNewCode(e.target.value)} - placeholder="Code (e.g. fr)" - className="input-base text-xs py-1 w-20 shrink-0" - /> - setNewName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleAddLocale(); if (e.key === 'Escape') setShowAdd(false) }} - placeholder="Name (e.g. French)" - className="input-base text-xs py-1 flex-1" - /> + {showAdd && ( +
+
+ setNewCode(e.target.value)} + placeholder="fr" + className="input-base" + /> + setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddLocale(); if (e.key === 'Escape') { setShowAdd(false); setNewCode(''); setNewName('') } }} + placeholder="French" + className="input-base" + /> +
+
+ + +
+
+ )} + + {!showAdd && ( + + )}
- + ))}
- )} -
- {locales.map(l => ( + {/* ── No locale prompt ── */} + {!activeLocale && ( +

+ Add a locale above to begin editing. +

+ )} + + {/* ── Subtitles Tab ── */} + {activeLocale && tab === 'subtitles' && ( +
+ {subsLoading ? ( +

Loading…

+ ) : ( + <> + {subs.length === 0 && ( +

+ No subtitles for this scene and locale. +

+ )} + {subs.map(s => ( + + ))} + + {/* Add new subtitle */} +
+ New Subtitle +
+ + setNewSubStart(e.target.value)} + className="input-base" + placeholder="0.0" + /> + + + setNewSubEnd(e.target.value)} + className="input-base" + placeholder="3.0" + /> + +
+ +