From 0cc5fa7986db6f30796a7fc05663404ab445df71 Mon Sep 17 00:00:00 2001 From: sepgh <13250403+sepgh@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:36:07 +0330 Subject: [PATCH 1/4] feat: subtitle in manifest, css customization for subtitles --- .../controller/CustomCssController.java | 41 ++- .../engine/editor/service/CompileService.java | 12 +- .../editor/service/ManifestService.java | 14 +- editor/frontend/src/api/customCss.ts | 12 +- .../src/components/editor/CustomCssPanel.tsx | 267 ++++++++++++++++-- .../com/engine/runtime/RuntimeServer.java | 12 +- runtime/src/main/resources/client/default.css | 8 +- runtime/src/main/resources/client/index.html | 2 + 8 files changed, 312 insertions(+), 56 deletions(-) diff --git a/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java b/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java index f796499..a5a8895 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/CustomCssController.java @@ -10,14 +10,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.Set; /** - * Manage the project's custom.css file for runtime UI customization. + * Manage the project's custom CSS files for runtime UI customization. + * Supports three files: custom.css (general), buttons.css, subtitles.css. */ @RestController @RequestMapping("/api/custom-css") public class CustomCssController { + private static final Set ALLOWED_FILES = Set.of("custom", "buttons", "subtitles"); + private final ProjectService projectService; public CustomCssController(ProjectService projectService) { @@ -26,24 +30,45 @@ public CustomCssController(ProjectService projectService) { @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE) public ResponseEntity getCustomCss() throws IOException { - Path cssFile = cssPath(); + return readCssFile("custom"); + } + + @PutMapping(consumes = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity> saveCustomCss(@RequestBody String content) throws IOException { + return writeCssFile("custom", content); + } + + @GetMapping(value = "/{name}", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity getNamedCss(@PathVariable String name) throws IOException { + if (!ALLOWED_FILES.contains(name)) return ResponseEntity.notFound().build(); + return readCssFile(name); + } + + @PutMapping(value = "/{name}", consumes = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity> saveNamedCss(@PathVariable String name, + @RequestBody String content) throws IOException { + if (!ALLOWED_FILES.contains(name)) return ResponseEntity.notFound().build(); + return writeCssFile(name, content); + } + + private ResponseEntity readCssFile(String name) throws IOException { + Path cssFile = cssPath(name); if (Files.exists(cssFile)) { return ResponseEntity.ok(Files.readString(cssFile, StandardCharsets.UTF_8)); } return ResponseEntity.ok(""); } - @PutMapping(consumes = MediaType.TEXT_PLAIN_VALUE) - public ResponseEntity> saveCustomCss(@RequestBody String content) throws IOException { - Path cssFile = cssPath(); + private ResponseEntity> writeCssFile(String name, String content) throws IOException { + Path cssFile = cssPath(name); Files.writeString(cssFile, content, StandardCharsets.UTF_8); return ResponseEntity.ok(Map.of( - "message", "custom.css saved", + "message", name + ".css saved", "path", cssFile.toAbsolutePath().toString() )); } - private Path cssPath() { - return projectService.getCurrentProjectPath().resolve("custom.css"); + private Path cssPath(String name) { + return projectService.getCurrentProjectPath().resolve(name + ".css"); } } diff --git a/editor/backend/src/main/java/com/engine/editor/service/CompileService.java b/editor/backend/src/main/java/com/engine/editor/service/CompileService.java index 5680e19..364e689 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/CompileService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/CompileService.java @@ -271,10 +271,12 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, buildReadme(projectName), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - // custom.css (user-editable runtime styles) - Path customCss = projectDir.resolve("custom.css"); - if (Files.exists(customCss)) { - Files.copy(customCss, distDir.resolve("custom.css"), StandardCopyOption.REPLACE_EXISTING); + // User-editable runtime CSS files (buttons, subtitles, general) + for (String cssName : List.of("buttons.css", "subtitles.css", "custom.css")) { + Path cssFile = projectDir.resolve(cssName); + if (Files.exists(cssFile)) { + Files.copy(cssFile, distDir.resolve(cssName), StandardCopyOption.REPLACE_EXISTING); + } } // Create dist.zip @@ -318,7 +320,7 @@ private void createZip(Path distDir, Path outputBase, Path assetsDir, Path zipFi Files.newOutputStream(zipFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING))) { - // Add dist/ contents (manifest, runtime.jar, scripts, README, custom.css) + // Add dist/ contents (manifest, runtime.jar, scripts, README, CSS files) try (var walk = Files.walk(distDir)) { walk.filter(Files::isRegularFile).forEach(p -> { String name = "game/" + distDir.relativize(p).toString(); diff --git a/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java b/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java index b6f65ba..10191c7 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java @@ -59,8 +59,8 @@ public Path generateManifest() { List> edgeList = buildEdges(reachable, config, jdbc); manifest.put("edges", edgeList); - // Localization - manifest.put("localization", buildLocalization(jdbc)); + // Localization (filtered to reachable scenes) + manifest.put("localization", buildLocalization(reachable, jdbc)); // Write to file Path outFile = projectDir.resolve(MANIFEST_FILE); @@ -351,7 +351,7 @@ private List> buildTransAudioTracks(String edgeId, // ── Localization ────────────────────────────────────────────────────────── - private Map buildLocalization(JdbcTemplate jdbc) { + private Map buildLocalization(Set reachableSceneIds, JdbcTemplate jdbc) { Map loc = new LinkedHashMap<>(); loc.put("locales", jdbc.queryForList("SELECT code, name FROM locales ORDER BY code") @@ -359,7 +359,9 @@ private Map buildLocalization(JdbcTemplate jdbc) { loc.put("subtitles", jdbc.queryForList( "SELECT id, scene_id, locale_code, start_time, end_time, text FROM subtitle_entries ORDER BY scene_id, locale_code, start_time") - .stream().map(r -> { + .stream() + .filter(r -> reachableSceneIds.contains(r.get("scene_id"))) + .map(r -> { Map s = new LinkedHashMap<>(); s.put("id", r.get("id")); s.put("sceneId", r.get("scene_id")); @@ -372,7 +374,9 @@ private Map buildLocalization(JdbcTemplate jdbc) { loc.put("decisionTranslations", jdbc.queryForList( "SELECT id, decision_key, scene_id, locale_code, label FROM decision_translations ORDER BY scene_id, locale_code") - .stream().map(r -> { + .stream() + .filter(r -> reachableSceneIds.contains(r.get("scene_id"))) + .map(r -> { Map dt = new LinkedHashMap<>(); dt.put("id", r.get("id")); dt.put("decisionKey", r.get("decision_key")); diff --git a/editor/frontend/src/api/customCss.ts b/editor/frontend/src/api/customCss.ts index b66c034..2d92faa 100644 --- a/editor/frontend/src/api/customCss.ts +++ b/editor/frontend/src/api/customCss.ts @@ -1,13 +1,17 @@ const BASE = '/api/custom-css' -export async function getCustomCss(): Promise { - const res = await fetch(BASE) +export type CssFileName = 'custom' | 'buttons' | 'subtitles' + +export async function getCustomCss(name: CssFileName = 'custom'): Promise { + const url = name === 'custom' ? BASE : `${BASE}/${name}` + const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.text() } -export async function saveCustomCss(content: string): Promise { - const res = await fetch(BASE, { +export async function saveCustomCss(content: string, name: CssFileName = 'custom'): Promise { + const url = name === 'custom' ? BASE : `${BASE}/${name}` + const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: content, diff --git a/editor/frontend/src/components/editor/CustomCssPanel.tsx b/editor/frontend/src/components/editor/CustomCssPanel.tsx index d999ca1..6166158 100644 --- a/editor/frontend/src/components/editor/CustomCssPanel.tsx +++ b/editor/frontend/src/components/editor/CustomCssPanel.tsx @@ -1,7 +1,50 @@ -import { useState, useEffect, useRef } from 'react' -import { getCustomCss, saveCustomCss } from '@/api/customCss' +import { useState, useEffect, useRef, useCallback } from 'react' +import { getCustomCss, saveCustomCss, type CssFileName } from '@/api/customCss' -export default function CustomCssPanel() { +// ── Tab definitions ────────────────────────────────────────────────────────── + +interface TabDef { + key: CssFileName + label: string + description: string + placeholder: string +} + +const TABS: TabDef[] = [ + { + key: 'buttons', + label: 'Buttons', + description: 'Style decision buttons and their container. Loaded as buttons.css.', + placeholder: + '/* Decision button styling */\n\n' + + '.decision-btn {\n border-radius: 20px;\n font-size: 16px;\n}\n\n' + + '.decision-btn:hover {\n background: rgba(255,255,255,0.2);\n}', + }, + { + key: 'subtitles', + label: 'Subtitles', + description: 'Style the subtitle overlay and text. Loaded as subtitles.css.', + placeholder: + '/* Subtitle styling */\n\n' + + '#subtitle-text {\n font-size: 22px;\n background: rgba(0,0,0,0.85);\n color: #ffe066;\n}\n\n' + + '#subtitle-container {\n bottom: 10%;\n}', + }, + { + key: 'custom', + label: 'General', + description: 'General overrides for any runtime element. Loaded last as custom.css.', + placeholder: + '/* General runtime overrides */\n\n' + + ':root {\n --arvexis-accent: #ff6b6b;\n}\n\n' + + '#stage {\n background: #111;\n}', + }, +] + +const REF_TAB_KEY = '__reference__' + +// ── Single-tab editor ──────────────────────────────────────────────────────── + +function CssTabEditor({ tab }: { tab: TabDef }) { const [content, setContent] = useState('') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -11,18 +54,19 @@ export default function CustomCssPanel() { useEffect(() => { setLoading(true) - getCustomCss() + setError(null) + getCustomCss(tab.key) .then(setContent) .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load')) .finally(() => setLoading(false)) - }, []) + }, [tab.key]) - async function handleSave() { + const handleSave = useCallback(async () => { setSaving(true) setError(null) setSaved(false) try { - await saveCustomCss(content) + await saveCustomCss(content, tab.key) setSaved(true) setTimeout(() => setSaved(false), 3000) } catch (e: unknown) { @@ -30,14 +74,13 @@ export default function CustomCssPanel() { } finally { setSaving(false) } - } + }, [content, tab.key]) function handleKeyDown(e: React.KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() handleSave() } - // Tab inserts 2 spaces if (e.key === 'Tab') { e.preventDefault() const ta = textareaRef.current @@ -47,17 +90,18 @@ export default function CustomCssPanel() { const val = ta.value const newVal = val.substring(0, start) + ' ' + val.substring(end) setContent(newVal) - requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = start + 2 - }) + requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = start + 2 }) } } return ( -
-
-

Custom CSS

-
+
+
+

+ {tab.description}{' '} + Ctrl+S to save. +

+
{saved && Saved ✓} {error && {error}}
- -
-

- Override the default runtime styles. This file is loaded after default.css in the runtime player. - Press Ctrl+S to save. -

-
-
{loading ? (
Loading…
@@ -84,14 +120,193 @@ export default function CustomCssPanel() {