diff --git a/src/app/app.ts b/src/app/app.ts
index d08911b..123dc83 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1,8 +1,9 @@
-import { Component, inject } from '@angular/core';
+import { Component, inject, effect, PLATFORM_ID } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HeaderMenu } from './features/header-menu/header-menu';
import { PopupService } from './services/popup.service';
-import { NgComponentOutlet } from '@angular/common';
+import { SettingsService } from './services/settings.service';
+import { NgComponentOutlet, isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-root',
@@ -12,4 +13,19 @@ import { NgComponentOutlet } from '@angular/common';
})
export class App {
protected popupService = inject(PopupService);
+ protected settingsService = inject(SettingsService);
+ private platformId = inject(PLATFORM_ID);
+
+ constructor() {
+ effect(() => {
+ if (isPlatformBrowser(this.platformId)) {
+ const isDark = this.settingsService.darkMode();
+ document.documentElement.classList.toggle('dark-theme', isDark);
+ document.documentElement.classList.toggle('light-theme', !isDark);
+
+ const lang = this.settingsService.language();
+ document.documentElement.lang = lang.toLowerCase();
+ }
+ });
+ }
}
diff --git a/src/app/features/header-menu/header-menu.html b/src/app/features/header-menu/header-menu.html
index fc65c19..2304f6d 100644
--- a/src/app/features/header-menu/header-menu.html
+++ b/src/app/features/header-menu/header-menu.html
@@ -1,19 +1,21 @@
diff --git a/src/app/features/header-menu/header-menu.scss b/src/app/features/header-menu/header-menu.scss
index 94057b2..924dd64 100644
--- a/src/app/features/header-menu/header-menu.scss
+++ b/src/app/features/header-menu/header-menu.scss
@@ -24,17 +24,23 @@
align-items: center;
gap: 48px;
- span:not(.icon-40) {
+ .menu-item, a:not(.settings) {
+ /* 기존 span 스타일을 공통 클래스와 태그로 확장 */
+ all: unset; /* 버튼 기본 스타일 제거 */
font-size: 18px;
- line-height: 40px;
+ height: 40px;
+ align-content: center;
vertical-align: middle;
white-space: nowrap;
+ font-family: inherit;
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
&.nav-link {
font-weight: 700;
- cursor: pointer;
- color: oklch(0.5 0.15 250); // Primary color highlight (blue-ish)
-
+ color: oklch(0.5 0.15 250);
transition: opacity 0.3s;
&:hover {
@@ -44,7 +50,17 @@
&.dummy-link {
cursor: not-allowed;
+ opacity: 0.6;
}
+
+ strong {
+ font-weight: 700;
+ }
+ }
+
+ /* 구체적인 아이콘 예외 처리 */
+ .icon-40 {
+ height: auto;
}
}
@@ -70,6 +86,10 @@
padding: 10px;
}
+ strong {
+ font-weight: 900 !important;
+ }
+
span {
/*text-box-trim: trim-both;
text-box-edge: cap alphabetic;*/
@@ -83,6 +103,11 @@
padding: 12px 20px;
.settings {
+ border: none;
+ outline: none;
+ background-color: inherit;
+ padding: 0;
+
position: relative;
z-index: 10;
cursor: pointer;
diff --git a/src/app/features/header-menu/header-menu.ts b/src/app/features/header-menu/header-menu.ts
index 7c33dd4..5835517 100644
--- a/src/app/features/header-menu/header-menu.ts
+++ b/src/app/features/header-menu/header-menu.ts
@@ -3,6 +3,7 @@ import { RouterLink } from '@angular/router';
import { NgOptimizedImage } from '@angular/common';
import { PopupService } from '../../services/popup.service';
import { SettingsPopup } from '../settings/settings';
+import { TranslationService } from '../../services/translation.service';
@Component({
selector: 'header-menu',
@@ -12,6 +13,7 @@ import { SettingsPopup } from '../settings/settings';
})
export class HeaderMenu {
private popupService = inject(PopupService);
+ protected translation = inject(TranslationService).translation;
onSettingsClick() {
console.log('HeaderMenu: Settings clicked');
diff --git a/src/app/features/settings/popup.html b/src/app/features/settings/popup.html
index 3f8a3fa..eb30415 100644
--- a/src/app/features/settings/popup.html
+++ b/src/app/features/settings/popup.html
@@ -1,38 +1,40 @@
![profile]()
-
{{ userName }}
+
{{ settingsService.userName() }}
-
+
match_case
- Romanisation
+ {{ translation.settings.romanisation }}
-
+
-
+
dark_mode
- Dark Mode
+ {{ translation.settings.darkMode }}
-
+
+
diff --git a/src/app/features/settings/settings.ts b/src/app/features/settings/settings.ts
index a0a3efe..2e1c2f8 100644
--- a/src/app/features/settings/settings.ts
+++ b/src/app/features/settings/settings.ts
@@ -4,6 +4,8 @@ import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
import { Switch } from '../../components/switch/switch';
import { NgOptimizedImage } from '@angular/common';
import { PopupService } from '../../services/popup.service';
+import { SettingsService, Language } from '../../services/settings.service';
+import { TranslationService } from '../../services/translation.service';
@Component({
selector: 'settings-popup',
@@ -14,6 +16,8 @@ import { PopupService } from '../../services/popup.service';
})
export class SettingsPopup {
private popupService = inject(PopupService);
+ protected settingsService = inject(SettingsService);
+ protected translation = inject(TranslationService).translation;
/**
* 팝업의 위치 (기본값은 'center')
@@ -26,7 +30,17 @@ export class SettingsPopup {
{ label: '日本語', value: 'JP' },
];
- userName: string = "Guest";
+ onLanguageChange(lang: Language) {
+ this.settingsService.setLanguage(lang);
+ }
+
+ onDarkModeChange(isDark: boolean) {
+ this.settingsService.setDarkMode(isDark);
+ }
+
+ onRomanisationChange(value: boolean) {
+ this.settingsService.setRomanisation(value);
+ }
/**
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
new file mode 100644
index 0000000..9f63fed
--- /dev/null
+++ b/src/app/i18n/en.ts
@@ -0,0 +1,26 @@
+export const EN = {
+ settings: {
+ title: 'Settings',
+ language: 'Language',
+ appearance: 'Appearance',
+ darkMode: 'Dark Mode',
+ romanisation: 'Romanisation',
+ guest: 'Guest',
+ chooseLanguage: 'Choose Language'
+ },
+ menu: {
+ levels: 'Levels',
+ community: 'Community',
+ leaderboard: 'Leaderboard',
+ about: 'About',
+ knownIssues: 'Known Issues',
+ login: 'Login',
+ signup: 'Signup',
+ },
+ common: {
+ close: 'Close',
+ save: 'Save'
+ }
+};
+
+export type Translation = typeof EN;
diff --git a/src/app/i18n/jp.ts b/src/app/i18n/jp.ts
new file mode 100644
index 0000000..daf3c4c
--- /dev/null
+++ b/src/app/i18n/jp.ts
@@ -0,0 +1,26 @@
+import { Translation } from './en';
+
+export const JP: Translation = {
+ settings: {
+ title: '設定',
+ language: '言語',
+ appearance: '外観',
+ darkMode: 'ダークモード',
+ romanisation: 'ローマ字表記',
+ guest: 'ゲスト',
+ chooseLanguage: '言語を選択',
+ },
+ menu: {
+ levels: 'レベル',
+ community: 'コミュニティ',
+ leaderboard: 'リーダーボード',
+ about: '情報',
+ knownIssues: '既知の問題',
+ login: 'ログイン',
+ signup: '新規登録',
+ },
+ common: {
+ close: '閉じる',
+ save: '保存',
+ },
+};
diff --git a/src/app/i18n/kr.ts b/src/app/i18n/kr.ts
new file mode 100644
index 0000000..b71d937
--- /dev/null
+++ b/src/app/i18n/kr.ts
@@ -0,0 +1,26 @@
+import { Translation } from './en';
+
+export const KR: Translation = {
+ settings: {
+ title: '설정',
+ language: '언어',
+ appearance: '화면 설정',
+ darkMode: '다크 모드',
+ romanisation: '로마자 표기',
+ guest: '게스트',
+ chooseLanguage: '언어 선택',
+ },
+ menu: {
+ levels: '레벨',
+ community: '커뮤니티',
+ leaderboard: '리더보드',
+ about: '정보',
+ knownIssues: '알려진 문제',
+ login: '로그인',
+ signup: '회원가입',
+ },
+ common: {
+ close: '닫기',
+ save: '저장',
+ },
+};
diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts
new file mode 100644
index 0000000..0fb4088
--- /dev/null
+++ b/src/app/services/settings.service.ts
@@ -0,0 +1,98 @@
+import { Injectable, signal, inject, PLATFORM_ID, computed, InjectionToken } from '@angular/core';
+import { isPlatformBrowser, isPlatformServer } from '@angular/common';
+
+// Angular 21 SSR 환경에서 서버 요청 객체를 가져오기 위한 토큰 정의
+export const REQUEST = new InjectionToken('REQUEST');
+
+export type Language = 'EN' | 'KR' | 'JP';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class SettingsService {
+ private platformId = inject(PLATFORM_ID);
+
+ // 서버 사이드에서만 주입되는 Request 객체를 가져옵니다.
+ private request = inject(REQUEST, { optional: true });
+
+ // Signals for state
+ private _language = signal('EN');
+ private _darkMode = signal(true);
+ private _romanisation = signal(false);
+ private _userName = signal('Guest');
+
+ // Public computed or read-only signals
+ language = computed(() => this._language());
+ darkMode = computed(() => this._darkMode());
+ romanisation = computed(() => this._romanisation());
+ userName = computed(() => this._userName());
+
+ constructor() {
+ this.initializeSettings();
+ }
+
+ private initializeSettings() {
+ if (isPlatformBrowser(this.platformId)) {
+ // 클라이언트: 브라우저 API 사용
+ this._language.set((this.getCookie('lang') as Language) || 'EN');
+ this._darkMode.set(this.getCookie('darkMode') !== 'false');
+ this._romanisation.set(this.getCookie('romanisation') === 'true');
+ this._userName.set(this.getCookie('userName') || 'Guest');
+ } else if (isPlatformServer(this.platformId)) {
+ // 서버: 요청 객체의 쿠키 읽기
+ // Express 요청 객체의 경우 headers['cookie']를 사용합니다.
+ const cookieHeader = (this.request as any)?.headers?.['cookie'] || (this.request as any)?.headers?.cookie;
+ if (cookieHeader) {
+ const cookies = this.parseServerCookies(cookieHeader);
+ if (cookies['lang']) this._language.set(cookies['lang'] as Language);
+ if (cookies['darkMode'] !== undefined) this._darkMode.set(cookies['darkMode'] !== 'false');
+ if (cookies['romanisation'] !== undefined) this._romanisation.set(cookies['romanisation'] === 'true');
+ if (cookies['userName']) this._userName.set(cookies['userName']);
+ }
+ }
+ }
+
+ private parseServerCookies(cookieString: string): Record {
+ const cookies: Record = {};
+ cookieString.split(';').forEach(c => {
+ const [key, value] = c.trim().split('=');
+ if (key && value) cookies[key] = decodeURIComponent(value);
+ });
+ return cookies;
+ }
+
+ setLanguage(lang: Language) {
+ this._language.set(lang);
+ this.setCookie('lang', lang);
+ }
+
+ setDarkMode(isDark: boolean) {
+ this._darkMode.set(isDark);
+ this.setCookie('darkMode', isDark.toString());
+ }
+
+ setRomanisation(value: boolean) {
+ this._romanisation.set(value);
+ this.setCookie('romanisation', value.toString());
+ }
+
+ setUserName(name: string) {
+ this._userName.set(name);
+ this.setCookie('userName', name);
+ }
+
+ private getCookie(name: string): string | undefined {
+ if (isPlatformBrowser(this.platformId)) {
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(';').shift();
+ }
+ return undefined;
+ }
+
+ private setCookie(name: string, value: string) {
+ if (isPlatformBrowser(this.platformId)) {
+ document.cookie = `${name}=${value}; path=/; max-age=31536000`; // 1 year
+ }
+ }
+}
diff --git a/src/app/services/translation.service.ts b/src/app/services/translation.service.ts
new file mode 100644
index 0000000..9fec3a0
--- /dev/null
+++ b/src/app/services/translation.service.ts
@@ -0,0 +1,32 @@
+import { inject, Injectable, computed } from '@angular/core';
+import { SettingsService } from './settings.service';
+import { EN, Translation } from '../i18n/en';
+import { KR } from '../i18n/kr';
+import { JP } from '../i18n/jp';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class TranslationService {
+ private settingsService = inject(SettingsService);
+
+ /**
+ * 내부 시그널: 실제 번역 데이터 객체
+ */
+ private _translation = computed(() => {
+ const lang = this.settingsService.language();
+ switch (lang) {
+ case 'KR': return KR;
+ case 'JP': return JP;
+ default: return EN;
+ }
+ });
+
+ /**
+ * Proxy를 통한 깔끔한 접근용 객체.
+ * {{ t.settings.title }} 처럼 () 없이 접근해도 내부적으로 _t() 시그널을 읽습니다.
+ */
+ readonly translation = new Proxy({} as Translation, {
+ get: (_, prop) => this._translation()[prop as keyof Translation]
+ });
+}