From 7b864afc49b5337492326ac8210b41c7b4a53545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=B0=BD=ED=98=84?= Date: Sat, 3 Oct 2026 07:32:36 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=BC=EC=95=84=EB=85=B8=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=20=ED=99=94=EB=A9=B4=EC=9D=84=20=ED=95=9C=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=20=EC=A0=9C=EA=B3=B5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면 문구와 도움말 및 PWA 정보를 별도 한국어 리소스로 구성한다. --- frontend/index.html | 10 ++-- frontend/src/i18n/ko.ts | 102 ++++++++++++++++++++++++++++++++++++++++ frontend/src/main.tsx | 3 ++ frontend/vite.config.ts | 6 +-- 4 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 frontend/src/i18n/ko.ts diff --git a/frontend/index.html b/frontend/index.html index ebd62e0..10b30c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + @@ -7,8 +7,8 @@ 'unsafe-inline' for script-src is needed for the error overlay below; tighten by moving it to a separate .js file if desired. --> - Bach to Basics - + Bach to Basics 피아노 학습 + @@ -82,7 +82,7 @@ el.style.display = 'block'; el.textContent = isDev ? 'JS ERROR:\n' + msg + '\n\nFile: ' + src + ':' + line + '\n\n' + (err && err.stack ? err.stack : '') - : 'Something went wrong. Please reload the page.'; + : '오류가 발생했습니다. 페이지를 새로고침해 주세요.'; }; window.addEventListener('unhandledrejection', function(e) { var el = document.getElementById('error-overlay'); @@ -90,7 +90,7 @@ if (isDev) { el.textContent = (el.textContent || '') + '\n\nUNHANDLED PROMISE:\n' + (e.reason && e.reason.stack ? e.reason.stack : String(e.reason)); } else if (!el.textContent) { - el.textContent = 'Something went wrong. Please reload the page.'; + el.textContent = '오류가 발생했습니다. 페이지를 새로고침해 주세요.'; } }); diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts new file mode 100644 index 0000000..0294c2f --- /dev/null +++ b/frontend/src/i18n/ko.ts @@ -0,0 +1,102 @@ +const translations: Record = { + Piano: '건반', Notes: '내려오는 음표', Sheet: '악보', All: '전체', + Import: '가져오기', Settings: '설정', Appearance: '화면', Keyboard: '건반', + 'Falling notes': '내려오는 음표', Overlays: '보조 표시', Playback: '재생', + 'No file loaded': '불러온 파일이 없습니다', 'Import a file': '파일 가져오기', + 'or drag & drop anywhere': '또는 파일을 화면 어디에나 놓으세요', + 'No MIDI device': '연결된 MIDI 장치 없음', 'MIDI error': 'MIDI 오류', + Stop: '정지', Play: '재생', Pause: '일시정지', Resume: '계속 재생', + Loop: '구간 반복', Speed: '연주 속도', 'Wait mode': '따라치기 모드', + MIDI: 'MIDI', MusicXML: 'MusicXML', 'PDF sheet music': 'PDF 악보', MP3: 'MP3', + 'Left hand': '왼손', 'Right hand': '오른손', Other: '기타', + Left: '왼손', Right: '오른손', Both: '양손', + Natural: '흰건반', Sharps: '검은건반', 'C only': '도만 표시', None: '표시 안 함', + Violet: '보라색', Classic: '기본', Ocean: '바다', Forest: '숲', Cascade: '물결', Custom: '사용자 지정', + White: '흰색', Ivory: '아이보리', Labels: '음이름', 'Key color': '건반 색상', + 'Note filter': '음표 필터', 'Octave grid': '옥타브 구분선', + 'Min note height': '최소 음표 높이', 'Note roundness': '음표 모서리', 'Note outline': '음표 테두리', + 'White score background': '악보 배경을 흰색으로 표시', + 'Finger numbers': '손가락 번호', Generate: '생성', Regenerate: '다시 생성', + 'Also on falling notes': '내려오는 음표에도 표시', 'Measure numbers': '마디 번호', + 'Beat lines': '박자선', 'Sustain pedal': '서스테인 페달', + 'Sustained note ghost': '지속 음표 잔상', 'Impact effect': '타건 효과', + Off: '끄기', Bloom: '퍼짐', Side: '측면', Trail: '잔상', + 'Note window': '입력 판정 범위', Transpose: '조옮김', 'Audio offset': '소리 지연 보정', + 'Scroll to seek': '스크롤로 재생 위치 이동', Instrument: '악기', Practice: '연습', + 'Wait hand': '따라칠 손', 'Hand volumes': '손별 음량', 'Speed trainer': '속도 훈련', + 'Start speed': '시작 속도', 'End speed': '종료 속도', Step: '증가 폭', + 'Reset to defaults': '기본값으로 되돌리기', 'Click again to confirm reset': '한 번 더 눌러 초기화 확인', + 'Playing': '재생 중', 'current time': '현재 시간', 'total duration': '전체 재생 시간', + 'Playback position': '재생 위치', 'Playback speed': '연주 속도', + 'Close settings': '설정 닫기', 'Open settings': '설정 열기', + 'Dismiss error': '오류 닫기', + 'Switch to light mode': '밝은 화면으로 전환', 'Switch to dark mode': '어두운 화면으로 전환', + 'Select MIDI input device': 'MIDI 입력 장치 선택', + 'Show only the piano keyboard view': '건반만 표시', + 'Show only the falling notes view': '내려오는 음표만 표시', + 'Show only the sheet music view': '악보만 표시', + 'Show all views at once': '모든 화면을 함께 표시', + 'Stop and return to start': '정지하고 처음으로 이동', + 'Pause until you play the correct note': '올바른 음을 누를 때까지 재생 대기', + 'Clear loop': '반복 구간 지우기', 'Start loop': '반복 시작점 지정', + 'Set loop start to current position': '현재 위치를 반복 시작점으로 지정', + 'Set loop end to current position': '현재 위치를 반복 종료점으로 지정', + 'Toggle metronome': '메트로놈 켜기 또는 끄기', + 'Export current score': '현재 악보 내보내기', + 'Load a file to enable export': '내보내려면 먼저 파일을 불러오세요', + 'Save the current score in another format': '현재 악보를 다른 형식으로 저장', + 'Reset all settings to their default values': '모든 설정을 기본값으로 되돌리기', + 'Something went wrong. Please reload the page.': '오류가 발생했습니다. 페이지를 새로고침해 주세요.', +}; + +const fragments: Array<[RegExp, string]> = [ + [/^Connected: (.+)$/, '$1 연결됨'], + [/^Mute (left|right) hand$/, '$1손 소리 끄기'], + [/^Unmute (left|right) hand$/, '$1손 소리 켜기'], + [/^No (MIDI|MusicXML) data in this document$/, '이 문서에는 $1 데이터가 없습니다'], + [/^Waiting for you to play the correct note$/, '올바른 음을 연주할 때까지 기다리는 중'], + [/^No file loaded - drag & drop or click ↑ Import$/, '불러온 파일이 없습니다. 파일을 놓거나 ↑ 가져오기를 누르세요'], +]; + +function translate(value: string): string { + if (translations[value]) return translations[value]; + for (const [pattern, replacement] of fragments) { + if (pattern.test(value)) return value.replace(pattern, replacement).replace('left손', '왼손').replace('right손', '오른손'); + } + return value; +} + +function translateNode(root: Node): void { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) { + const original = node.nodeValue ?? ''; + const trimmed = original.trim(); + if (!trimmed) continue; + const translated = translate(trimmed); + if (translated !== trimmed) node.nodeValue = original.replace(trimmed, translated); + } + if (root instanceof Element) { + const elements = [root, ...root.querySelectorAll('*')]; + for (const element of elements) { + for (const attribute of ['title', 'aria-label', 'placeholder']) { + const original = element.getAttribute(attribute); + if (!original) continue; + const translated = translate(original); + if (translated !== original) element.setAttribute(attribute, translated); + } + } + } +} + +export function installKoreanUi(): void { + document.documentElement.lang = 'ko'; + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) translateNode(node); + if (mutation.type === 'characterData' && mutation.target.parentNode) translateNode(mutation.target.parentNode); + } + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + requestAnimationFrame(() => translateNode(document.body)); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6f975f5..2c65d49 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,9 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; +import { installKoreanUi } from "./i18n/ko"; + +installKoreanUi(); // StrictMode deliberately double-mounts in dev, which conflicts with Pixi.js's // async init - Application.destroy() fires before init() resolves. diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 54e23ae..20d12e1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,10 +18,10 @@ export default defineConfig({ // App icons + display name shown on install / home-screen / dock. manifest: { - name: "Bach to Basics", - short_name: "Bach to Basics", + name: "Bach to Basics 피아노 학습", + short_name: "Bach 피아노", description: - "Browser-based piano practice tool - falling notes, sheet music, MIDI, and practice tools", + "악보와 내려오는 음표를 보며 MIDI 피아노를 연습하는 한국어 학습 도구", theme_color: "#9333ea", // matches the purple accent background_color: "#0a0a0a", display: "standalone",