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] + }); +}