From 5a31ffb802624b63f676c6b527ed28578137f5ac Mon Sep 17 00:00:00 2001 From: Maestro Date: Sun, 14 Jun 2026 10:39:20 +0200 Subject: [PATCH] feat(#61): line-by-line projector mode with parallel translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement line-by-line display mode for Projector with bilingual support. - New line-by-line projector mode: current line full-size, next line preview, previous line dimmed - ×1 (solo) and ×2 (flow) modes for flexible display - Bilingual translation support: singable (full-size) and reference (dimmed) - PresenterDashboard redesign: controls below slide strip - Admin UI for language/translation_type per song part - Keyboard shortcuts: ←→ (navigate), B (blank), F (fullscreen), 1/2 (lines) - BroadcastChannel sync for two-window presenter setup - Migration 0011: Add `language` and `translation_type` to `song_parts` - `buildLineSlides()` and `swapPrimaryLang()` in format.ts for line interleaving - `SongPartDto` extended with new fields - SongEditor redesigned with translation editor - Language + translation type selectors per part - Admin API routes: POST/PATCH song parts now accept language/translationType - Updated docs/schema.md with new song_parts fields - Updated docs/api.md with new endpoint parameters - Added i18n strings in de.json and en.json - Removed ci.yml branch pollution (keep main, develop only) - Fixed admin API_URL to use env var instead of hardcoded localhost - Localized all PresenterDashboard strings - Fixed prettier formatting in api.ts - Updated screenshot baselines for new Projector and PresenterDashboard UI Closes #61 Co-Authored-By: Claude Haiku 4.5 --- apps/admin/app/api/v1/[...path]/route.ts | 2 +- apps/admin/app/dev/song-preview/page.tsx | 6 + .../app/domains/songbooks/SongEditor.tsx | 554 ++++++++---- apps/admin/app/domains/songbooks/SongForm.tsx | 5 +- apps/admin/app/domains/songbooks/types.ts | 10 +- apps/admin/app/globals.css | 258 +++++- apps/admin/app/lib/api.ts | 8 +- .../app/songbooks/[id]/songs/new/page.tsx | 2 +- apps/api/src/repositories/songs.ts | 11 +- apps/api/src/routes/admin/songbooks.ts | 3 + apps/api/src/schemas.ts | 3 + .../app/components/PresenterDashboard.tsx | 826 +++++++++++++++--- apps/songbook/app/components/Projector.tsx | 492 +++++++++-- apps/songbook/app/components/ReaderLayout.tsx | 19 +- apps/songbook/app/components/SongReader.tsx | 5 +- apps/songbook/app/components/SongView.tsx | 49 +- apps/songbook/app/lib/format.ts | 80 ++ apps/songbook/package.json | 2 +- docs/api.md | 4 +- docs/frontend.md | 81 +- docs/schema.md | 4 +- .../migrations/0010_overrated_apocalypse.sql | 2 + .../db/migrations/meta/0010_snapshot.json | 802 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/index.ts | 14 +- packages/i18n/src/messages/de.json | 24 +- packages/i18n/src/messages/en.json | 24 +- packages/types/src/index.ts | 3 + 28 files changed, 2905 insertions(+), 395 deletions(-) create mode 100644 packages/db/migrations/0010_overrated_apocalypse.sql create mode 100644 packages/db/migrations/meta/0010_snapshot.json diff --git a/apps/admin/app/api/v1/[...path]/route.ts b/apps/admin/app/api/v1/[...path]/route.ts index 2566165f..84e4b7c6 100644 --- a/apps/admin/app/api/v1/[...path]/route.ts +++ b/apps/admin/app/api/v1/[...path]/route.ts @@ -6,7 +6,7 @@ const API_BASE = process.env.API_URL ?? 'https://api.sdarm.life'; async function proxy(req: Request, ctx: { params: Promise<{ path: string[] }> }) { const { path } = await ctx.params; - const url = new URL(req.url); + const url = new URL(req.url, 'http://localhost'); const target = `${API_BASE}/api/v1/${path.join('/')}${url.search}`; const apiKey = process.env.API_KEY; diff --git a/apps/admin/app/dev/song-preview/page.tsx b/apps/admin/app/dev/song-preview/page.tsx index 330fa1db..4973a84d 100644 --- a/apps/admin/app/dev/song-preview/page.tsx +++ b/apps/admin/app/dev/song-preview/page.tsx @@ -17,6 +17,8 @@ const MOCK_SONG: SongDto = { sortOrder: 0, lyrics: 'Коли в [Em]мене запитають:\nЧи існує щастя десь?\nЯк дійти до того краю,\nДе потіха для сердець.\nДе не [Em]ллються тихо сльози\nВід гріха і марноти?\nЯ ска[C]жу, що щастя в Бозі\nЯ знай[Am]шла і зн[H7]айдеш [Em]ти.', + language: null, + translationType: 'original', }, { id: 2, @@ -25,6 +27,8 @@ const MOCK_SONG: SongDto = { sortOrder: 1, lyrics: 'Щастя не [Em]ховається, щастя не тік[C]ає!\nЩастя укр[H7]ивається в Господа руц[Em]і.\nСерце що стиск[Am]ається, серце що шук[Em]ає,\nЩастям наповн[H7]яється тільки у Христ[Em]і.', + language: null, + translationType: 'original', }, { id: 3, @@ -33,6 +37,8 @@ const MOCK_SONG: SongDto = { sortOrder: 2, lyrics: 'Коли в [Em]мене запитають,\nДе любові джерело?\nЗвідки сили я черпаю,\nЩоб робити всім добро?\nДе на[Em]дія не вмирає,\nІ де мрія ожива?\nВідпо[C]вім, що на Голгофі\nДже[Am]рело я [H7]це знайш[Em]ла!', + language: null, + translationType: 'original', }, ], sheets: [], diff --git a/apps/admin/app/domains/songbooks/SongEditor.tsx b/apps/admin/app/domains/songbooks/SongEditor.tsx index f440dd75..829de1d2 100644 --- a/apps/admin/app/domains/songbooks/SongEditor.tsx +++ b/apps/admin/app/domains/songbooks/SongEditor.tsx @@ -3,9 +3,22 @@ import { useState, useRef, useEffect, useLayoutEffect, useMemo } from 'react'; import { updateSong, createPart, updatePart, deletePart, uploadSheet, deleteSheet, fetchSong } from './repository'; import { r2url } from '../../lib/api'; -import type { SongDto, SongPartDto, SongSheetDto, SongPartType } from '@sdarm/types'; +import type { SongDto, SongPartDto, SongSheetDto, SongPartType, TranslationType } from '@sdarm/types'; +import type { TranslationDraft } from './types'; const PART_TYPES: SongPartType[] = ['verse', 'chorus', 'bridge', 'intro', 'outro', 'coda']; +const PART_LABELS: Record = { + verse: '', // dynamic: Verse N + chorus: 'Chorus', + bridge: 'Bridge', + intro: 'Intro', + outro: 'Outro', + coda: 'Coda', +}; +const TRANSLATION_TYPES: { value: TranslationType; label: string }[] = [ + { value: 'singable', label: 'Singable (can be sung to the melody)' }, + { value: 'reference', label: 'Reference (literal, display only)' }, +]; const MAJOR_CHORDS = ['C', 'D', 'E', 'F', 'G', 'A', 'H']; const MINOR_CHORDS = ['Cm', 'Dm', 'Em', 'Fm', 'Gm', 'Am', 'Hm']; const MODIFIERS: { label: string; char: string }[] = [ @@ -24,6 +37,7 @@ type MetaState = { title: string; author: string; copyright: string; + language: string; }; // ── Label ↔ type dictionary ────────────────────────────────────────────────── @@ -70,6 +84,31 @@ function initText(parts: SongPartDto[]): string { .join('\n\n'); } +function partsByLang(parts: SongPartDto[]): { originals: SongPartDto[]; translations: Map } { + const originals: SongPartDto[] = []; + const translations = new Map(); + for (const p of [...parts].sort((a, b) => a.sortOrder - b.sortOrder)) { + if (!p.translationType || p.translationType === 'original') { + originals.push(p); + } else { + const key = `${p.language ?? ''}::${p.translationType}`; + const arr = translations.get(key) ?? []; + arr.push(p); + translations.set(key, arr); + } + } + return { originals, translations }; +} + +function initTranslationDrafts(parts: SongPartDto[]): TranslationDraft[] { + const { translations } = partsByLang(parts); + return Array.from(translations.entries()).map(([, ps]) => ({ + language: ps[0].language ?? '', + translationType: ps[0].translationType as TranslationType, + text: initText(ps), + })); +} + /** * One blank-line-separated block is one part, always. The first line is the * label because `initText` put it there — the dictionary decides the part's @@ -98,6 +137,20 @@ function parseText(text: string): ParsedPart[] { return parts; } +// Splits translation text into blocks of lines (by blank lines), without needing section headers. +// Block index matches the original section index. +function parseTranslationBlocks(text: string): string[][] { + return text + .split(/\n\s*\n/) + .map((block) => + block + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + ) + .filter((block) => block.length > 0); +} + // ── Chord line parser (for preview) ────────────────────────────────────────── type ChordToken = { chord: string | null; text: string }; @@ -126,15 +179,20 @@ type Props = { song: SongDto }; export default function SongEditor({ song }: Props) { const taRef = useRef(null); + const { originals: initialOriginals } = partsByLang(song.parts); + const primaryLang = initialOriginals[0]?.language ?? ''; + const [meta, setMeta] = useState({ number: song.number, title: song.title, author: song.author ?? '', copyright: song.copyright ?? '', + language: primaryLang, }); + const [translations, setTranslations] = useState(() => initTranslationDrafts(song.parts)); const [metaStatus, setMetaStatus] = useState('idle'); - const [text, setText] = useState(() => initText(song.parts)); + const [text, setText] = useState(() => initText(initialOriginals)); const [savedParts, setSavedParts] = useState(song.parts); const [sheets, setSheets] = useState(song.sheets); @@ -147,6 +205,9 @@ export default function SongEditor({ song }: Props) { const [saveDone, setSaveDone] = useState(false); const parsed = useMemo(() => parseText(text), [text]); + const parsedTrBlocks = useMemo(() => translations.map((tr) => parseTranslationBlocks(tr.text)), [translations]); + // Flat sequence of translation lines across all sections — matches original lyric lines globally + const flatTrLines = useMemo(() => parsedTrBlocks.map((blocks) => blocks.flat()), [parsedTrBlocks]); // ── Autogrow textarea ────────────────────────────────────────────────────── useLayoutEffect(() => { @@ -179,12 +240,12 @@ export default function SongEditor({ song }: Props) { // ── Selection-aware section insertion ────────────────────────────────────── - function wrapWithSection(type: SongPartType) { + function wrapWithSection(type: SongPartType, customLabel?: string) { const ta = taRef.current; if (!ta) return; const start = ta.selectionStart; const end = ta.selectionEnd; - const label = defaultLabel(type, parsed); + const label = customLabel ?? defaultLabel(type, parsed); const before = text.slice(0, start); const selected = text.slice(start, end); @@ -260,35 +321,89 @@ export default function SongEditor({ song }: Props) { setSaveError(null); setSaveDone(false); try { - const existing = [...savedParts].sort((a, b) => a.sortOrder - b.sortOrder); + const { originals: existingOriginals } = partsByLang(savedParts); + const lang = meta.language || null; // A save that would wipe a song is never what an editor meant. Emptying // the textarea is how you would clear one part, not how you would ask for // every part of a stored song to be deleted — and there is no undo here. - if (parsed.length === 0 && existing.length > 0) { + if (parsed.length === 0 && existingOriginals.length > 0) { setSaveError('Refusing to delete every section. Clear them one at a time if that is really the intent.'); return; } + // Save original parts for (let i = 0; i < parsed.length; i++) { const p = parsed[i]; - if (i < existing.length) { - await updatePart(song.id, existing[i].id, { + if (i < existingOriginals.length) { + await updatePart(song.id, existingOriginals[i].id, { type: p.type, label: p.label, sortOrder: i, lyrics: p.lyrics, + language: lang, + translationType: 'original', }); } else { - await createPart(song.id, { type: p.type, label: p.label, sortOrder: i, lyrics: p.lyrics }); + await createPart(song.id, { + type: p.type, + label: p.label, + sortOrder: i, + lyrics: p.lyrics, + language: lang, + translationType: 'original', + }); + } + } + for (let i = parsed.length; i < existingOriginals.length; i++) { + await deletePart(song.id, existingOriginals[i].id); + } + + // Save translation parts + const { translations: existingTranslationsMap } = partsByLang(savedParts); + const existingTranslationGroups = Array.from(existingTranslationsMap.values()); + for (let ti = 0; ti < translations.length; ti++) { + const draft = translations[ti]; + const tParsed = parseText(draft.text); + const existing = existingTranslationGroups[ti] ?? []; + for (let i = 0; i < tParsed.length; i++) { + const p = tParsed[i]; + if (i < existing.length) { + await updatePart(song.id, existing[i].id, { + type: p.type, + label: p.label, + sortOrder: i, + lyrics: p.lyrics, + language: draft.language || null, + translationType: draft.translationType, + }); + } else { + await createPart(song.id, { + type: p.type, + label: p.label, + sortOrder: i, + lyrics: p.lyrics, + language: draft.language || null, + translationType: draft.translationType, + }); + } + } + for (let i = tParsed.length; i < existing.length; i++) { + await deletePart(song.id, existing[i].id); } } - for (let i = parsed.length; i < existing.length; i++) { - await deletePart(song.id, existing[i].id); + // Delete removed translation groups + for (let ti = translations.length; ti < existingTranslationGroups.length; ti++) { + for (const p of existingTranslationGroups[ti]) { + await deletePart(song.id, p.id); + } } + const fresh = await fetchSong(song.id); setSavedParts(fresh.parts); - setText(initText(fresh.parts)); + const { originals: freshOriginals } = partsByLang(fresh.parts); + setText(initText(freshOriginals)); + setTranslations(initTranslationDrafts(fresh.parts)); setSaveDone(true); } catch (e) { setSaveError(String(e)); @@ -327,84 +442,125 @@ export default function SongEditor({ song }: Props) { return (
- {/* ── Metadata ── */} -
-
-
- - setMeta((m) => ({ ...m, number: Number(e.target.value) }))} - /> -
- -
- - setMeta((m) => ({ ...m, title: e.target.value }))} - /> -
- -
- - setMeta((m) => ({ ...m, author: e.target.value }))} - /> -
- -
- - setMeta((m) => ({ ...m, copyright: e.target.value }))} - /> -
-
-
- -
-
- {/* ── Editor + Preview ── */}
- {/* Left: lyrics editor + sheet music */}
+
+
+ setMeta((m) => ({ ...m, number: Number(e.target.value) }))} + /> + setMeta((m) => ({ ...m, title: e.target.value }))} + /> + setMeta((m) => ({ ...m, author: e.target.value }))} + /> + setMeta((m) => ({ ...m, copyright: e.target.value }))} + /> + setMeta((m) => ({ ...m, language: e.target.value }))} + /> + + {uploadError && ( + + {uploadError} + + )} + {sortedSheets.map((sheet) => { + const href = r2url(sheet.key) ?? '#'; + return ( +
+ + {sheet.type === 'pdf' ? 'PDF' : 'IMG'} + + +
+ ); + })} + +
+
Section {PART_TYPES.map((t) => ( ))} +
Chord @@ -451,7 +607,8 @@ export default function SongEditor({ song }: Props) { />
- Sections are separated by a blank line. First line of each block = label (Verse 1, Chorus, Bridge…). + Separate sections with a blank line. First line = label:{' '} + Verse 1, Verse 2, Chorus, Refrain, Bridge, Intro, Outro, Coda — always write it explicitly.
{saveError && ( @@ -459,74 +616,88 @@ export default function SongEditor({ song }: Props) { {saveError}
)} - {saveDone &&
Saved.
} + {saveDone &&
Saved.
}
- {/* Sheet Music */} -

- Sheet Music -

- - {uploadError && ( -
- {uploadError} -
- )} - - {sortedSheets.length > 0 && ( -
- {sortedSheets.map((sheet) => { - const href = r2url(sheet.key) ?? '#'; - return ( -
- - {sheet.type === 'image' ? ( - {sheet.key} - ) : ( - PDF - )} - - -
- ); - })} -
+ {/* Translations */} +
+ + Translations + + {translations.length === 0 && ( + + )} +
+ {translations.length === 0 ? ( +

+ No translations yet — add one to enable parallel display in the projector. +

+ ) : ( + translations.map((tr, ti) => ( +
+
+ + setTranslations((ts) => ts.map((t, i) => (i === ti ? { ...t, language: e.target.value } : t))) + } + /> + + +
+