Apply Language Settings
This commit is contained in:
1 parent
39113a4d39
commit
5dfb1dba03
11 files changed
+296
-27
No files matched your search
+18
-2
@@ -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 { RouterOutlet } from '@angular/router';
|
||||||
import { HeaderMenu } from './features/header-menu/header-menu';
|
import { HeaderMenu } from './features/header-menu/header-menu';
|
||||||
import { PopupService } from './services/popup.service';
|
import { PopupService } from './services/popup.service';
|
||||||
import { NgComponentOutlet } from '@angular/common';
|
import { SettingsService } from './services/settings.service';
|
||||||
|
import { NgComponentOutlet, isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -12,4 +13,19 @@ import { NgComponentOutlet } from '@angular/common';
|
|||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected popupService = inject(PopupService);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
<div class="menu-left">
|
<div class="menu-left">
|
||||||
<div class="brand" [routerLink]="['/']">
|
<a class="brand" [routerLink]="['/']">
|
||||||
<img ngSrc="/logo_inverted.svg" alt="logo" height="20" width="59.52" />
|
<img ngSrc="/logo_inverted.svg" alt="logo" height="20" width="59.52" />
|
||||||
<span><strong>ADOFAI Editor Forum</strong></span>
|
<span><strong>ADOFAI Editor Forum</strong></span>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<span class="dummy-link">Levels</span>
|
<span class="menu-item dummy-link">{{ translation.menu.levels }}</span>
|
||||||
<span class="dummy-link">Community</span>
|
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
|
||||||
<span class="dummy-link">Leaderboard</span>
|
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
||||||
<span class="dummy-link">About</span>
|
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-right">
|
<div class="menu-right">
|
||||||
<span class="nav-link" [routerLink]="['/known-issues']">Known Issues</span>
|
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
|
||||||
<span class="dummy-link"><strong>Login</strong></span>
|
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
|
||||||
<span class="dummy-link">Signup</span>
|
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
|
||||||
|
|
||||||
<span class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()">settings</span>
|
<button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
|
||||||
|
settings
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,17 +24,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 48px;
|
gap: 48px;
|
||||||
|
|
||||||
span:not(.icon-40) {
|
.menu-item, a:not(.settings) {
|
||||||
|
/* 기존 span 스타일을 공통 클래스와 태그로 확장 */
|
||||||
|
all: unset; /* 버튼 기본 스타일 제거 */
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 40px;
|
height: 40px;
|
||||||
|
align-content: center;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
&.nav-link {
|
&.nav-link {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
color: oklch(0.5 0.15 250);
|
||||||
color: oklch(0.5 0.15 250); // Primary color highlight (blue-ish)
|
|
||||||
|
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -44,7 +50,17 @@
|
|||||||
|
|
||||||
&.dummy-link {
|
&.dummy-link {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 구체적인 아이콘 예외 처리 */
|
||||||
|
.icon-40 {
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +86,10 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 900 !important;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
/*text-box-trim: trim-both;
|
/*text-box-trim: trim-both;
|
||||||
text-box-edge: cap alphabetic;*/
|
text-box-edge: cap alphabetic;*/
|
||||||
@@ -83,6 +103,11 @@
|
|||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
|
|
||||||
.settings {
|
.settings {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background-color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RouterLink } from '@angular/router';
|
|||||||
import { NgOptimizedImage } from '@angular/common';
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { PopupService } from '../../services/popup.service';
|
import { PopupService } from '../../services/popup.service';
|
||||||
import { SettingsPopup } from '../settings/settings';
|
import { SettingsPopup } from '../settings/settings';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'header-menu',
|
selector: 'header-menu',
|
||||||
@@ -12,6 +13,7 @@ import { SettingsPopup } from '../settings/settings';
|
|||||||
})
|
})
|
||||||
export class HeaderMenu {
|
export class HeaderMenu {
|
||||||
private popupService = inject(PopupService);
|
private popupService = inject(PopupService);
|
||||||
|
protected translation = inject(TranslationService).translation;
|
||||||
|
|
||||||
onSettingsClick() {
|
onSettingsClick() {
|
||||||
console.log('HeaderMenu: Settings clicked');
|
console.log('HeaderMenu: Settings clicked');
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
<app-popup (closeRequest)="close()" [position]="position()">
|
<app-popup (closeRequest)="close()" [position]="position()">
|
||||||
<div header-left class="profile-section">
|
<div header-left class="profile-section">
|
||||||
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
|
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
|
||||||
<span>{{ userName }}</span>
|
<span>{{ settingsService.userName() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<section class="settings-category">
|
<section class="settings-category">
|
||||||
<label class="category-header">Language</label>
|
<label class="category-header">{{ translation.settings.language }}</label>
|
||||||
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<dropdown [options]="languages"
|
<dropdown [options]="languages"
|
||||||
placeholder="Choose Language"
|
[placeholder]="translation.settings.chooseLanguage"
|
||||||
[defaultIndex]="0"
|
[value]="settingsService.language()"
|
||||||
|
(selectionChange)="onLanguageChange($event)"
|
||||||
icon="language"></dropdown>
|
icon="language"></dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-name">
|
<div class="settings-name">
|
||||||
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span>
|
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span>
|
||||||
<span>Romanisation</span>
|
<span>{{ translation.settings.romanisation }}</span>
|
||||||
</div>
|
</div>
|
||||||
<switch></switch>
|
<switch [isActive]="settingsService.romanisation()" (toggle)="onRomanisationChange($event)"></switch>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-category">
|
<section class="settings-category">
|
||||||
<label class="category-header">Appearance</label>
|
<label class="category-header">{{ translation.settings.appearance }}</label>
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-name">
|
<div class="settings-name">
|
||||||
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span>
|
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span>
|
||||||
<span>Dark Mode</span>
|
<span>{{ translation.settings.darkMode }}</span>
|
||||||
</div>
|
</div>
|
||||||
<switch></switch>
|
<switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</app-popup>
|
</app-popup>
|
||||||
@@ -4,6 +4,8 @@ import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
|
|||||||
import { Switch } from '../../components/switch/switch';
|
import { Switch } from '../../components/switch/switch';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { PopupService } from '../../services/popup.service';
|
import { PopupService } from '../../services/popup.service';
|
||||||
|
import { SettingsService, Language } from '../../services/settings.service';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'settings-popup',
|
selector: 'settings-popup',
|
||||||
@@ -14,6 +16,8 @@ import { PopupService } from '../../services/popup.service';
|
|||||||
})
|
})
|
||||||
export class SettingsPopup {
|
export class SettingsPopup {
|
||||||
private popupService = inject(PopupService);
|
private popupService = inject(PopupService);
|
||||||
|
protected settingsService = inject(SettingsService);
|
||||||
|
protected translation = inject(TranslationService).translation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업의 위치 (기본값은 'center')
|
* 팝업의 위치 (기본값은 'center')
|
||||||
@@ -26,7 +30,17 @@ export class SettingsPopup {
|
|||||||
{ label: '日本語', value: 'JP' },
|
{ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
|
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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: '保存',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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: '저장',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<any>('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<Language>('EN');
|
||||||
|
private _darkMode = signal<boolean>(true);
|
||||||
|
private _romanisation = signal<boolean>(false);
|
||||||
|
private _userName = signal<string>('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<string, string> {
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user