Compare commits
96
Commits
688fea9033
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe01a365a8 | ||
|
|
f68aa880db | ||
|
|
a2ec29adca | ||
|
|
0908064688 | ||
|
|
25a9bca291 | ||
|
|
7363684b2f | ||
|
|
2f5e79e300 | ||
|
|
912bd0d755 | ||
|
|
e01e628d94 | ||
|
|
214dfb498e | ||
|
|
f343f3e977 | ||
|
|
1a49cd132b | ||
|
|
713f0ef75b | ||
|
|
3bece368b3 | ||
|
|
405f2808a0 | ||
|
|
88d2b6a546 | ||
|
|
e86f500d8f | ||
|
|
31f78de70b | ||
|
|
96894c81e0 | ||
|
|
1885ed3926 | ||
|
|
96f62b0e7c | ||
|
|
0586005191 | ||
|
|
9092c97b9c | ||
|
|
c21845a579 | ||
|
|
5de6d111f2 | ||
|
|
0baba36f7f | ||
|
|
dceac30c39 | ||
|
|
9c7f3cf542 | ||
|
|
35d20eab76 | ||
|
|
3a415a2525 | ||
|
|
3f33c0dfa5 | ||
|
|
7a0af6cc4a | ||
|
|
0c97bc696d | ||
|
|
4605fd7ee4 | ||
|
|
be48e8ef89 | ||
|
|
02f936b345 | ||
|
|
0801ed3c70 | ||
|
|
b0ae9717f5 | ||
|
|
ae9e86461e | ||
|
|
94ca7defb2 | ||
|
|
ed7ecb2994 | ||
|
|
9fbd2db197 | ||
|
|
f610849c17 | ||
|
|
57588d4604 | ||
|
|
7cf7c49937 | ||
|
|
81bfd4a2b8 | ||
|
|
44b7d6097e | ||
|
|
1f5453f755 | ||
|
|
f97e9592ad | ||
|
|
4dc35c5aac | ||
|
|
98d122c8ef | ||
|
|
29f79678e8 | ||
|
|
e6b6947e08 | ||
|
|
33e8b61065 | ||
|
|
83ba362c59 | ||
|
|
372c5a53d7 | ||
|
|
4e356289a3 | ||
|
|
9995b8c1e4 | ||
|
|
da1789c1fe | ||
|
|
436a01b455 | ||
|
|
f2ac841dfd | ||
|
|
4974a699dc | ||
|
|
9043aa0535 | ||
|
|
2d6e0995c7 | ||
|
|
7b1b47fd05 | ||
|
|
bac135853d | ||
|
|
7b5ba462cb | ||
|
|
32911f244a | ||
|
|
8c3d9e4dda | ||
|
|
1a09c96309 | ||
|
|
69774fab76 | ||
|
|
5dfb1dba03 | ||
|
|
39113a4d39 | ||
|
|
68ebed341f | ||
|
|
a9276ce86a | ||
|
|
c3f24f5e6d | ||
|
|
9e6a846bfa | ||
|
|
d37bec5cb9 | ||
|
|
0405b15b01 | ||
|
|
450da37d5b | ||
|
|
fd182bfce0 | ||
|
|
8d55b478eb | ||
|
|
898df6a318 | ||
|
|
010771b22a | ||
|
|
d946ee4026 | ||
|
|
869fb890d3 | ||
|
|
92e0784084 | ||
|
|
751eb73a94 | ||
|
|
c0cd104d8d | ||
|
|
913bdea398 | ||
|
|
f71dc03f67 | ||
|
|
88f72e767f | ||
|
|
99fd518ddb | ||
|
|
68655d3078 | ||
|
|
50f551df11 | ||
|
|
213a1d5fc0 |
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
Chrome >= 111
|
||||||
|
Firefox >= 113
|
||||||
|
Edge >= 111
|
||||||
|
not dead
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.angular
|
||||||
|
README.md
|
||||||
|
package-lock.json
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Gemini CLI
|
||||||
|
/.gemini
|
||||||
|
|
||||||
# Compiled output
|
# Compiled output
|
||||||
/dist
|
/dist
|
||||||
/tmp
|
/tmp
|
||||||
@@ -42,3 +45,5 @@ __screenshots__/
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
*.tar
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# 1. Build Stage
|
||||||
|
FROM node:24-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 파일 복사 및 설치
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# 소스 코드 복사 및 빌드
|
||||||
|
COPY . .
|
||||||
|
RUN npm install -g @angular/cli
|
||||||
|
RUN ng build --configuration production
|
||||||
|
|
||||||
|
# 2. Production Stage
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 빌드 스테이지에서 생성된 dist 폴더만 복사
|
||||||
|
COPY --from=build /app/dist /app/dist
|
||||||
|
|
||||||
|
# 포트 설정 (Angular SSR 기본 포트: 4000)
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
# 서버 실행 (package.json의 serve:ssr:aef-component-test 참고)
|
||||||
|
CMD ["node", "dist/aef-component-test/server/server.mjs"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright © 2026 Yunizun.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -1,59 +1,3 @@
|
|||||||
# AefComponentTest
|
# Component Test
|
||||||
|
|
||||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
|
For Testing Designed Components for ADOFAI Editor Forum Web
|
||||||
|
|
||||||
## Development server
|
|
||||||
|
|
||||||
To start a local development server, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
|
||||||
|
|
||||||
## Code scaffolding
|
|
||||||
|
|
||||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate component component-name
|
|
||||||
```
|
|
||||||
|
|
||||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To build the project run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
|
||||||
|
|
||||||
## Running unit tests
|
|
||||||
|
|
||||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running end-to-end tests
|
|
||||||
|
|
||||||
For end-to-end (e2e) testing, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
|
||||||
+18
-1
@@ -32,7 +32,24 @@
|
|||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
"stylePreprocessorOptions": {
|
"stylePreprocessorOptions": {
|
||||||
"includePaths": ["src","src/styles"]
|
"includePaths": ["src", "src/styles"]
|
||||||
|
},
|
||||||
|
"server": "src/main.server.ts",
|
||||||
|
"outputMode": "server",
|
||||||
|
"security": {
|
||||||
|
"allowedHosts": [
|
||||||
|
"aef.kr",
|
||||||
|
"*.aef.kr",
|
||||||
|
"yunizun.org"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ssr": {
|
||||||
|
"entry": "src/server.ts"
|
||||||
|
},
|
||||||
|
"optimization": {
|
||||||
|
"scripts": true,
|
||||||
|
"styles": true,
|
||||||
|
"fonts": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트
|
||||||
|
|
||||||
|
$IMAGE_NAME = "aef-component-test"
|
||||||
|
$TAG = "0.0.9"
|
||||||
|
$OUTPUT_FILE = "aef-component-test.tar"
|
||||||
|
|
||||||
|
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
|
||||||
|
docker build --no-cache -t "$($IMAGE_NAME):$TAG" .
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "--- 이미지 추출 시작: $OUTPUT_FILE ---" -ForegroundColor Cyan
|
||||||
|
docker save -o $OUTPUT_FILE "$($IMAGE_NAME):$TAG"
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "성공! $OUTPUT_FILE 파일이 생성되었습니다." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "이미지 추출 중 오류가 발생했습니다." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "빌드 중 오류가 발생했습니다." -ForegroundColor Red
|
||||||
|
}
|
||||||
Generated
+389
-153
File diff suppressed because it is too large.
Load diff
+16
-10
@@ -6,24 +6,30 @@
|
|||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test",
|
||||||
|
"serve:ssr:aef-component-test": "node dist/aef-component-test/server/server.mjs"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.11.0",
|
"packageManager": "npm@11.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.4",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.4",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.4",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.4",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.4",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/platform-server": "^21.2.4",
|
||||||
|
"@angular/router": "^21.2.4",
|
||||||
|
"@angular/ssr": "^21.2.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.2.1",
|
"@angular/build": "^21.2.2",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.2.2",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.4",
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/node": "^20.17.19",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
||||||
|
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||||
|
import { appConfig } from './app.config';
|
||||||
|
import { serverRoutes } from './app.routes.server';
|
||||||
|
|
||||||
|
const serverConfig: ApplicationConfig = {
|
||||||
|
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||||
@@ -2,10 +2,12 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/
|
|||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes)
|
provideRouter(routes),
|
||||||
]
|
provideClientHydration(withEventReplay()),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
+11
-35
@@ -1,39 +1,15 @@
|
|||||||
|
<skip-links ngSkipHydration>
|
||||||
|
<button (click)="skipToContent()">{{ translation.common.skipToContent }}</button>
|
||||||
|
</skip-links>
|
||||||
|
|
||||||
<header-menu></header-menu>
|
<header-menu></header-menu>
|
||||||
|
|
||||||
<div class="container">
|
<main #content>
|
||||||
<h1>AEF Component Test</h1>
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
<section>
|
<div id="global-popup-root" class="popup-outlet">
|
||||||
<h2>Switch Component</h2>
|
@for (popup of popupService.activePopups(); track popup.component) {
|
||||||
<switch [(isActive)]="isToggled" (toggle)="handleToggle($event)"></switch>
|
<ng-container *ngComponentOutlet="popup.component; inputs: popup.inputs" />
|
||||||
<p>Switch is: {{ isToggled() ? 'ON' : 'OFF' }}</p>
|
}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Dropdown Component</h2>
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 40px; align-items: flex-start;">
|
|
||||||
<div>
|
|
||||||
<h3>Object Options</h3>
|
|
||||||
<dropdown
|
|
||||||
[options]="dropdownOptions"
|
|
||||||
[(value)]="selectedDropdownValue"
|
|
||||||
(selectionChange)="handleDropdownChange($event)"
|
|
||||||
icon="language">
|
|
||||||
</dropdown>
|
|
||||||
<p>Selected Value: {{ selectedDropdownValue() }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3>String Options (Simple)</h3>
|
|
||||||
<dropdown
|
|
||||||
[options]="['Small', 'Medium', 'Large']"
|
|
||||||
[(value)]="selectedSize"
|
|
||||||
placeholder="Choose Size">
|
|
||||||
</dropdown>
|
|
||||||
<p>Selected Size: {{ selectedSize() }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<router-outlet />
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||||
|
|
||||||
|
export const serverRoutes: ServerRoute[] = [
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
renderMode: RenderMode.Server,
|
||||||
|
},
|
||||||
|
];
|
||||||
+10
-1
@@ -1,3 +1,12 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { MainPage } from './pages/main/main';
|
||||||
|
import { KnownIssuesPage } from './pages/known-issues/known-issues';
|
||||||
|
import { ClockPage } from './pages/clock/clock';
|
||||||
|
import { LevelPage } from './pages/levels/levels';
|
||||||
|
|
||||||
export const routes: Routes = [];
|
export const routes: Routes = [
|
||||||
|
{ path: '', component: MainPage },
|
||||||
|
{ path: 'known-issues', component: KnownIssuesPage },
|
||||||
|
{ path: 'clock', component: ClockPage },
|
||||||
|
{ path: 'levels', component: LevelPage },
|
||||||
|
];
|
||||||
+4
-26
@@ -1,29 +1,7 @@
|
|||||||
.container {
|
:host {
|
||||||
padding: 40px;
|
min-height: 100dvh;
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
main:focus-visible {
|
||||||
margin-bottom: 40px;
|
outline: none;
|
||||||
padding: 20px;
|
|
||||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-top: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
+49
-18
@@ -1,32 +1,63 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component, inject, effect, PLATFORM_ID, ElementRef, viewChild } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { Switch } from './components/switch/switch';
|
import { HeaderMenu } from './features/header-menu/header-menu';
|
||||||
import { Dropdown, DropdownOption } from './components/dropdown/dropdown';
|
import { PopupService } from './services/popup.service';
|
||||||
import { HeaderMenu } from './ui/header-menu/header-menu';
|
import { SettingsService } from './services/settings.service';
|
||||||
|
import { NgComponentOutlet, isPlatformBrowser, isPlatformServer, DOCUMENT } from '@angular/common';
|
||||||
|
import { SkipLinks } from './components/skip-links/skip-links';
|
||||||
|
import { TranslationService } from './services/translation.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, Switch, Dropdown, HeaderMenu],
|
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
isToggled = signal(false);
|
protected popupService = inject(PopupService);
|
||||||
|
protected settingsService = inject(SettingsService);
|
||||||
|
protected translation = inject(TranslationService).translation;
|
||||||
|
private platformID = inject(PLATFORM_ID);
|
||||||
|
|
||||||
dropdownOptions: DropdownOption[] = [
|
private contentElement = viewChild<ElementRef<HTMLElement>>("content");
|
||||||
{ label: 'English', value: "EN" },
|
|
||||||
{ label: '한국어', value: "KR" },
|
|
||||||
{ label: '日本語', value: "JP" },
|
|
||||||
];
|
|
||||||
|
|
||||||
selectedDropdownValue = signal<any>(null);
|
private document = inject(DOCUMENT);
|
||||||
selectedSize = signal<string | null>(null);
|
private rootHtmlElement!: HTMLElement;
|
||||||
|
|
||||||
handleToggle(state: boolean) {
|
constructor() {
|
||||||
console.log('Toggle state:', state);
|
if (isPlatformServer(this.platformID)) {
|
||||||
|
this.rootHtmlElement = this.document.documentElement;
|
||||||
|
} else if (isPlatformBrowser(this.platformID)) {
|
||||||
|
this.rootHtmlElement = document.documentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDropdownChange(value: any) {
|
effect(() => {
|
||||||
console.log('Dropdown value:', value);
|
this.initializeStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeStatus() {
|
||||||
|
const isDark = this.settingsService.darkMode();
|
||||||
|
this.rootHtmlElement.classList.toggle('dark-theme', isDark);
|
||||||
|
this.rootHtmlElement.classList.toggle('light-theme', !isDark);
|
||||||
|
|
||||||
|
const lang = this.settingsService.language();
|
||||||
|
const langMap: Record<string, string> = {
|
||||||
|
KR: 'ko',
|
||||||
|
JP: 'ja',
|
||||||
|
EN: 'en',
|
||||||
|
};
|
||||||
|
this.rootHtmlElement.lang = langMap[lang] || 'en';
|
||||||
|
|
||||||
|
const reduceMotion = this.settingsService.reduceAnimation();
|
||||||
|
this.rootHtmlElement.classList.toggle('reduce-motion', reduceMotion);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipToContent() {
|
||||||
|
this.contentElement()!.nativeElement.tabIndex = 0;
|
||||||
|
this.contentElement()!.nativeElement.focus();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
this.contentElement()!.nativeElement.tabIndex = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<canvas class="canvas" #canvasElement></canvas>
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
NgZone,
|
||||||
|
OnDestroy,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { SettingsService } from '../../services/settings.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'brand-accent',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './brand-accent.html',
|
||||||
|
styleUrl: './brand-accent.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class BrandAccent implements OnDestroy {
|
||||||
|
canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('canvasElement');
|
||||||
|
private ctx!: CanvasRenderingContext2D;
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
|
drawGrid = input<boolean>(true);
|
||||||
|
|
||||||
|
private bgRendererScale = 1;
|
||||||
|
private canvasNativeSize: [number, number] = [0, 0];
|
||||||
|
private bgGradient!: CanvasGradient;
|
||||||
|
private gridGradient!: CanvasGradient;
|
||||||
|
|
||||||
|
private readonly offscreenCanvas!: OffscreenCanvas;
|
||||||
|
private readonly offscreenCtx!: OffscreenCanvasRenderingContext2D;
|
||||||
|
|
||||||
|
private resizeObserver?: ResizeObserver;
|
||||||
|
private intersectionObserver?: IntersectionObserver;
|
||||||
|
|
||||||
|
private rendererScale!: number;
|
||||||
|
|
||||||
|
private animation: {
|
||||||
|
id?: number;
|
||||||
|
startTime: number;
|
||||||
|
progress: number;
|
||||||
|
gridDimension: [number, number];
|
||||||
|
|
||||||
|
duration: number;
|
||||||
|
gridSquareSize: number;
|
||||||
|
} = {
|
||||||
|
startTime: 0,
|
||||||
|
progress: 0,
|
||||||
|
gridDimension: [0, 0],
|
||||||
|
|
||||||
|
duration: 30,
|
||||||
|
gridSquareSize: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private ngZone: NgZone) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
this.offscreenCanvas = new OffscreenCanvas(0, 0);
|
||||||
|
this.offscreenCtx = this.offscreenCanvas.getContext('2d', { colorSpace: 'srgb' })!;
|
||||||
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
// Trigger cache refresh when dark mode changes
|
||||||
|
const isDark = this.settingsService.darkMode();
|
||||||
|
if (this.ctx) {
|
||||||
|
this.cacheGradients(isDark);
|
||||||
|
this.cacheBackground(isDark);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNextRender(() => {
|
||||||
|
const nativeElement = this.canvasRef()?.nativeElement;
|
||||||
|
|
||||||
|
if (nativeElement) {
|
||||||
|
this.ctx = nativeElement.getContext('2d', { colorSpace: 'srgb' })!;
|
||||||
|
|
||||||
|
this.ngZone.runOutsideAngular(() => {
|
||||||
|
this.resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
const { inlineSize: width, blockSize: height } = entry.borderBoxSize[0];
|
||||||
|
this.resize(width, height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.resizeObserver.observe(nativeElement);
|
||||||
|
|
||||||
|
this.intersectionObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
if (!this.animation.id) this.animate(performance.now());
|
||||||
|
} else if (this.animation.id) {
|
||||||
|
cancelAnimationFrame(this.animation.id);
|
||||||
|
this.animation.id = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
);
|
||||||
|
this.intersectionObserver.observe(nativeElement);
|
||||||
|
|
||||||
|
// Safe Initialization
|
||||||
|
const rect = nativeElement.getBoundingClientRect();
|
||||||
|
this.resize(rect.width, rect.height);
|
||||||
|
|
||||||
|
this.animation.startTime = performance.now();
|
||||||
|
this.draw();
|
||||||
|
// this.animate(performance.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resize(width: number, height: number) {
|
||||||
|
const canvas = this.canvasRef()?.nativeElement;
|
||||||
|
if (canvas) {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
this.rendererScale = dpr;
|
||||||
|
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
|
||||||
|
if (this.offscreenCanvas) {
|
||||||
|
this.offscreenCanvas.width = width * this.bgRendererScale;
|
||||||
|
this.offscreenCanvas.height = height * this.bgRendererScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvasNativeSize = [width, height];
|
||||||
|
|
||||||
|
this.ctx.scale(dpr, dpr);
|
||||||
|
this.updateAnimationInfo();
|
||||||
|
this.animate(performance.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateAnimationInfo() {
|
||||||
|
this.animation.gridDimension = [
|
||||||
|
Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3,
|
||||||
|
Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3,
|
||||||
|
];
|
||||||
|
|
||||||
|
const isDark = this.settingsService.darkMode();
|
||||||
|
|
||||||
|
this.cacheGradients(isDark);
|
||||||
|
this.cacheBackground(isDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheGradients(isDark: boolean) {
|
||||||
|
let w = this.canvasNativeSize[0];
|
||||||
|
let h = this.canvasNativeSize[1];
|
||||||
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
|
if (!isDark) {
|
||||||
|
this.bgGradient = this.ctx.createLinearGradient(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
w * this.bgRendererScale,
|
||||||
|
h * this.bgRendererScale,
|
||||||
|
);
|
||||||
|
this.bgGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)');
|
||||||
|
this.bgGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gridGradient = this.ctx.createLinearGradient(0, 0, w, h);
|
||||||
|
if (isDark) {
|
||||||
|
// Dark mode: subtle light grid
|
||||||
|
this.gridGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
|
||||||
|
this.gridGradient.addColorStop(1, 'rgba(255, 255, 255, 0.4)');
|
||||||
|
} else {
|
||||||
|
// Light mode: dark grid
|
||||||
|
this.gridGradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
||||||
|
this.gridGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheBackground(isDark: boolean) {
|
||||||
|
if (!this.offscreenCtx) return;
|
||||||
|
|
||||||
|
const w = this.canvasNativeSize[0];
|
||||||
|
const h = this.canvasNativeSize[1];
|
||||||
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
|
this.offscreenCtx.clearRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
|
||||||
|
this.offscreenCtx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
|
if (!isDark) {
|
||||||
|
this.offscreenCtx.fillStyle = this.bgGradient;
|
||||||
|
this.offscreenCtx.fillRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
|
||||||
|
} else {
|
||||||
|
this.offscreenCtx.fillStyle = 'oklch(0.1776 0 0)';
|
||||||
|
this.offscreenCtx.fillRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
|
||||||
|
|
||||||
|
const ellipseDimension = [Math.max(100, w * 0.9421875), Math.max(200 / 3, h * (203 / 120))];
|
||||||
|
|
||||||
|
const ellipseX = w / 2;
|
||||||
|
const ellipseY = h * 0.5763888888 + ellipseDimension[1] / 2;
|
||||||
|
|
||||||
|
const blurScale =
|
||||||
|
Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) /
|
||||||
|
this.rendererScale;
|
||||||
|
|
||||||
|
this.offscreenCtx.filter = `blur(${blurScale * this.bgRendererScale}px)`;
|
||||||
|
|
||||||
|
this.offscreenCtx.beginPath();
|
||||||
|
this.offscreenCtx.ellipse(
|
||||||
|
ellipseX * this.bgRendererScale,
|
||||||
|
ellipseY * this.bgRendererScale,
|
||||||
|
(ellipseDimension[0] / 2) * this.bgRendererScale,
|
||||||
|
(ellipseDimension[1] / 2) * this.bgRendererScale,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2 * Math.PI,
|
||||||
|
);
|
||||||
|
this.offscreenCtx.fillStyle = 'oklch(0.2547 0.0913 29.02)';
|
||||||
|
this.offscreenCtx.fill();
|
||||||
|
|
||||||
|
this.offscreenCtx.filter = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate = (currentTime: number) => {
|
||||||
|
this.animation.id = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
this.update(currentTime);
|
||||||
|
this.draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
private update(currentTime: number) {
|
||||||
|
let elapsedTime: number;
|
||||||
|
elapsedTime = (currentTime - this.animation.startTime) / 1000;
|
||||||
|
|
||||||
|
const animationTime = elapsedTime % this.animation.duration;
|
||||||
|
this.animation.progress = animationTime / this.animation.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw() {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
||||||
|
const isDark = this.settingsService.darkMode();
|
||||||
|
|
||||||
|
if (this.offscreenCanvas) {
|
||||||
|
this.ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
|
this.ctx.imageSmoothingEnabled = false;
|
||||||
|
this.ctx.drawImage(
|
||||||
|
this.offscreenCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this.canvasNativeSize[0],
|
||||||
|
this.canvasNativeSize[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
// this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.drawGrid()) return;
|
||||||
|
|
||||||
|
if (!isDark) {
|
||||||
|
this.ctx.globalCompositeOperation = 'multiply';
|
||||||
|
} else {
|
||||||
|
this.ctx.globalCompositeOperation = 'screen';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.strokeStyle = this.gridGradient;
|
||||||
|
this.ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
const lineRenderMargin = this.animation.gridSquareSize * 2;
|
||||||
|
|
||||||
|
const xOffset =
|
||||||
|
(this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) /
|
||||||
|
2;
|
||||||
|
const yOffset =
|
||||||
|
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
|
||||||
|
2;
|
||||||
|
|
||||||
|
const movingOffset = this.settingsService.reduceAnimation() ? 0 : -(this.animation.gridSquareSize * this.animation.progress);
|
||||||
|
|
||||||
|
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
|
||||||
|
this.ctx.moveTo(
|
||||||
|
-lineRenderMargin,
|
||||||
|
y * this.animation.gridSquareSize + yOffset + movingOffset,
|
||||||
|
);
|
||||||
|
this.ctx.lineTo(
|
||||||
|
this.canvasNativeSize[0] + lineRenderMargin,
|
||||||
|
y * this.animation.gridSquareSize + yOffset + movingOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < this.animation.gridDimension[0]; x++) {
|
||||||
|
this.ctx.moveTo(
|
||||||
|
x * this.animation.gridSquareSize + xOffset + movingOffset,
|
||||||
|
-lineRenderMargin,
|
||||||
|
);
|
||||||
|
this.ctx.lineTo(
|
||||||
|
x * this.animation.gridSquareSize + xOffset + movingOffset,
|
||||||
|
this.canvasNativeSize[1] + lineRenderMargin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
if (this.animation.id) cancelAnimationFrame(this.animation.id);
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
this.intersectionObserver?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="clock-container">
|
||||||
|
<div class="clock-date">{{ dateString() }}</div>
|
||||||
|
<div class="clock-time">{{ timeString() }}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
font-family: inherit;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-date {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: light-dark(oklch(0.3211 0 0), oklch(0.6789 0 0)); // #333
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-time {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: light-dark(oklch(0 0 0), oklch(1 0 0)); // #000
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component, signal, computed, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'clock',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './clock.html',
|
||||||
|
styleUrl: './clock.scss',
|
||||||
|
})
|
||||||
|
export class Clock implements OnInit, OnDestroy {
|
||||||
|
private timer: any;
|
||||||
|
currentTime = signal(new Date());
|
||||||
|
|
||||||
|
dateString = computed(() => {
|
||||||
|
const d = this.currentTime();
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
timeString = computed(() => {
|
||||||
|
const d = this.currentTime();
|
||||||
|
let hours = d.getHours();
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
|
||||||
|
hours = hours % 12;
|
||||||
|
hours = hours ? hours : 12; // 0 should be 12
|
||||||
|
const hoursStr = String(hours).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hoursStr}:${minutes}:${seconds} ${ampm}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.currentTime.set(new Date());
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,34 @@
|
|||||||
|
<ng-template #inputTemplate>
|
||||||
|
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
|
||||||
|
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
|
||||||
|
[attr.placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" tabindex="-1" spellcheck="false"
|
||||||
|
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"
|
||||||
|
(focus)="onInputFocusIn()" (focusout)="onInputFocusOut()"></div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<div class="dropdown-box">
|
<div class="dropdown-box">
|
||||||
@if (icon()) {
|
@if (icon()?.trim()?.length ?? 0 > 0) {
|
||||||
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
|
<span class="material-symbols-outlined icon-24 prefix-icon" aria-hidden="true">{{ icon() }}</span>
|
||||||
}
|
}
|
||||||
<span class="placeholder">{{ selectedLabel() }}</span>
|
|
||||||
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span>
|
@if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
||||||
|
@else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
||||||
|
|
||||||
|
@if (!isOpen()) {
|
||||||
|
<span class="placeholder" [class.value-empty]="selectedOption() === undefined">{{ selectedLabel() }}</span>
|
||||||
|
}
|
||||||
|
<span class="material-symbols-outlined icon-24 chevron" aria-hidden="true" [class.open]="isOpen()">keyboard_arrow_down</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isOpen()) {
|
@if (isOpen()) {
|
||||||
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'">
|
<div class="dropdown-container" tabindex="-1">
|
||||||
@for (option of normalizedOptions(); track option.value; let i = $index) {
|
<ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
|
||||||
|
@for (option of filteredOptions(); track option.value; let i = $index) {
|
||||||
<li
|
<li
|
||||||
|
#optionItem
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
role="option"
|
role="option"
|
||||||
[id]="dropdownId + '-option-' + i"
|
[id]="dropdownID + '-option-' + i"
|
||||||
[class.selected]="option.value === value()"
|
[class.selected]="option.value === value()"
|
||||||
[class.focused]="i === focusedIndex()"
|
[class.focused]="i === focusedIndex()"
|
||||||
[attr.aria-selected]="option.value === value()"
|
[attr.aria-selected]="option.value === value()"
|
||||||
@@ -21,4 +37,5 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@@ -2,40 +2,54 @@
|
|||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
@use "styles/library" as library;
|
@use "styles/library" as library;
|
||||||
|
|
||||||
$dropdown_bg: oklch(1 0 0); // FFFFFF
|
$border_color: oklch(0 0 0 / 20%);
|
||||||
$dropdown_border_color: oklch(0 0 0 / 20%);
|
$border_dark_color: oklch(1 0 0 / 20%);
|
||||||
$dropdown_hover: oklch(0.9702 0 0); // F5F5F5
|
$hover_bg: oklch(0.9702 0 0); // #F5F5F5
|
||||||
$dropdown_selected: oklch(0.9738 0.0151 54.93); // FFF4ED
|
$hover_dark_bg: oklch(0.25 0 0);
|
||||||
$dropdown_accent: oklch(0.7086 0.1896 39.86); // #FF6E37
|
|
||||||
|
|
||||||
$dropdown_height: 40px;
|
$height: 40px;
|
||||||
$dropdown_border_radius: 8px;
|
$border_radius: 8px;
|
||||||
|
|
||||||
$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
$dropdown_animation_duration: 0.2s;
|
$default_animation_duration: 0.2s;
|
||||||
|
|
||||||
|
@property --hover-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --selected-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
padding: 0; margin: 0;
|
padding: 0; margin: 0;
|
||||||
|
|
||||||
background-color: $dropdown_bg;
|
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
||||||
border-radius: $dropdown_border_radius;
|
border-radius: $border_radius;
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:focus-visible {
|
max-width: 100%;
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset, 0 0 0 2px white, 0 0 0 4px $dropdown_accent;
|
|
||||||
|
&:has(.dropdown-input.focus-mode:focus-visible) {
|
||||||
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-box {
|
.dropdown-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 10px;
|
||||||
height: $dropdown_height;
|
height: $height;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
@@ -46,19 +60,49 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
&.value-empty { opacity: 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-input {
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
&:focus-visible { outline: none; }
|
||||||
|
&:empty:before {
|
||||||
|
content: attr(placeholder);
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.focus-mode) {
|
||||||
|
caret-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder, .dropdown-input {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.2; // descender(g, y 등)가 잘리지 않도록 행간 확보
|
height: 24px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
|
align-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
width: 24px; height: 24px;
|
width: 24px; height: 24px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: transform $dropdown_animation_duration $dropdown_animation_timing;
|
transition: transform var(--animation-duration) $animation_timing;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
@@ -67,36 +111,69 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-list {
|
.dropdown-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0; // 아이템이 꽉 차도록 패딩 제거
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
||||||
|
border-radius: $border_radius;
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
|
||||||
|
animation: slideIn var(--animation-duration) $animation_timing;
|
||||||
|
|
||||||
|
.dropdown-list {
|
||||||
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background-color: $dropdown_bg;
|
width: 100%;
|
||||||
border-radius: $dropdown_border_radius;
|
max-height: inherit;
|
||||||
z-index: 1000;
|
|
||||||
max-height: 240px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
animation: slideIn $dropdown_animation_duration $dropdown_animation_timing;
|
|
||||||
|
|
||||||
// 자식 요소의 배경색이 테두리를 침범하지 못하도록 하는 사용자님의 핵심 설계 복구
|
scrollbar-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%)) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
|
||||||
|
background-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
border-radius: $dropdown_border_radius;
|
border-radius: $border_radius;
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1001; // 테두리를 최상단으로!
|
z-index: 1001;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from { opacity: 0; transform: translateY(-8px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
transform: translateY(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,16 +183,33 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
line-height: 1.2; // descender 보호를 위해 1에서 1.2로 수정
|
align-content: center;
|
||||||
transition: background-color $dropdown_animation_duration $dropdown_animation_timing;
|
transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing, --selected-state calc(var(--animation-duration) / 3) $animation_timing;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklch,
|
||||||
|
color-mix(
|
||||||
|
in oklch,
|
||||||
|
transparent,
|
||||||
|
light-dark($hover_bg, $hover_dark_bg)
|
||||||
|
calc(var(--hover-state) * 100%)
|
||||||
|
),
|
||||||
|
light-dark(list.nth(library.$brand_palette, 1), list.nth(library.$brand_palette, 12))
|
||||||
|
calc(var(--selected-state) * 100%)
|
||||||
|
);
|
||||||
|
|
||||||
|
&.focused {
|
||||||
|
--animation-duration: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover, &.focused {
|
&:hover, &.focused {
|
||||||
background-color: $dropdown_hover;
|
--hover-state: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
background-color: $dropdown_selected;
|
--selected-state: 1;
|
||||||
color: $dropdown_accent;
|
color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import { Component, input, model, output, signal, HostListener, ElementRef, computed
|
import {
|
||||||
} from '@angular/core'
|
Component,
|
||||||
import { CommonModule } from '@angular/common'
|
computed,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
model,
|
||||||
|
OnInit,
|
||||||
|
output,
|
||||||
|
PLATFORM_ID,
|
||||||
|
signal,
|
||||||
|
untracked,
|
||||||
|
viewChild,
|
||||||
|
viewChildren
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
export interface DropdownOption {
|
export interface DropdownOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -14,40 +30,111 @@ export interface DropdownOption {
|
|||||||
templateUrl: './dropdown.html',
|
templateUrl: './dropdown.html',
|
||||||
styleUrl: './dropdown.scss',
|
styleUrl: './dropdown.scss',
|
||||||
host: {
|
host: {
|
||||||
'role': 'combobox',
|
role: 'combobox',
|
||||||
'aria-haspopup': 'listbox',
|
'aria-haspopup': 'listbox',
|
||||||
'[attr.aria-expanded]': 'isOpen()',
|
'[attr.aria-expanded]': 'isOpen()',
|
||||||
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null",
|
'[attr.aria-controls]': "isOpen() ? dropdownID + '-listbox' : null",
|
||||||
'[attr.aria-activedescendant]': "isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
|
'[attr.aria-activedescendant]': 'ariaActiveDescendant()',
|
||||||
'[attr.aria-label]': 'ariaLabel()',
|
'[attr.aria-label]': 'ariaLabel()',
|
||||||
'[class.open]': 'isOpen()',
|
'[class.open]': 'isOpen()',
|
||||||
'tabindex': '0',
|
tabindex: '0',
|
||||||
'(click)': 'toggle()',
|
'(click)': 'toggle()',
|
||||||
|
'(focusin)': 'onFocusIn($event)',
|
||||||
|
'(mousedown)': 'onMouseDown($event)',
|
||||||
|
'(keydown)': 'onKeyDown($event)',
|
||||||
'(keydown.enter)': 'onEnter($event)',
|
'(keydown.enter)': 'onEnter($event)',
|
||||||
'(keydown.space)': 'onSpaceDown($event)',
|
'(keydown.space)': 'onSpaceDown($event)',
|
||||||
'(keydown.escape)': 'close()',
|
'(keydown.escape)': 'close()',
|
||||||
'(keydown.arrowdown)': 'onArrowDown($event)',
|
'(keydown.arrowdown)': 'onArrowDown($event)',
|
||||||
'(keydown.arrowup)': 'onArrowUp($event)'
|
'(keydown.arrowup)': 'onArrowUp($event)',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export class Dropdown {
|
export class Dropdown implements OnInit {
|
||||||
|
translation = inject(TranslationService).translation;
|
||||||
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
searchInput = viewChild<ElementRef<HTMLElement>>('searchInput');
|
||||||
|
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
|
||||||
|
|
||||||
options = input<Array<DropdownOption | string>>([]);
|
options = input<Array<DropdownOption | string>>([]);
|
||||||
placeholder = input<string>('Select an option');
|
placeholder = input<string>('');
|
||||||
ariaLabel = input<string | undefined>(undefined);
|
ariaLabel = input<string | undefined>(undefined);
|
||||||
icon = input<string>();
|
icon = input<string>();
|
||||||
|
defaultIndex = input<number>();
|
||||||
value = model<any>(null);
|
value = model<any>(null);
|
||||||
selectionChange = output<any>();
|
selectionChange = output<any>();
|
||||||
|
|
||||||
isOpen = signal(false);
|
isOpen = signal(false);
|
||||||
|
inputFocusMode = signal(false);
|
||||||
focusedIndex = signal(-1);
|
focusedIndex = signal(-1);
|
||||||
|
searchQuery = signal('');
|
||||||
|
recreateKey = signal(0);
|
||||||
|
|
||||||
private static nextId = 0;
|
ariaActiveDescendant = computed(() => {
|
||||||
dropdownId = `dropdown-${Dropdown.nextId++}`;
|
return this.isOpen() && this.focusedIndex() >= 0
|
||||||
|
? this.dropdownID + '-option-' + this.focusedIndex()
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
constructor(private el: ElementRef) {}
|
private static nextID = 0;
|
||||||
|
private isLatestMouseDownInsideInput = false;
|
||||||
|
dropdownID = `dropdown-${Dropdown.nextID++}`;
|
||||||
|
private isLatestMouseDownInsideContainer = false;
|
||||||
|
|
||||||
|
constructor(private element: ElementRef) {
|
||||||
|
effect(() => this.filteredOptions());
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const index = this.focusedIndex();
|
||||||
|
const isDropdownOpen = this.isOpen();
|
||||||
|
|
||||||
|
if (isDropdownOpen && index >= 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.scrollToIndex(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const isDropdownOpen = this.isOpen();
|
||||||
|
|
||||||
|
if (!isDropdownOpen) {
|
||||||
|
if (untracked(this.inputFocusMode)) this.inputFocusMode.set(false);
|
||||||
|
this.searchQuery.set('');
|
||||||
|
|
||||||
|
const input = this.searchInput();
|
||||||
|
if (isPlatformBrowser(this.platformId) && input) {
|
||||||
|
let currentValue = untracked(this.normalizedOptions).find(
|
||||||
|
(o) => o.value === this.value(),
|
||||||
|
);
|
||||||
|
if (currentValue) input.nativeElement.innerText = currentValue.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const opts = this.normalizedOptions();
|
||||||
|
const dIndex = this.defaultIndex();
|
||||||
|
const currentValue = this.value();
|
||||||
|
|
||||||
|
if (currentValue === null && dIndex !== undefined && dIndex >= 0 && dIndex < opts.length) {
|
||||||
|
this.value.set(opts[dIndex].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToIndex(index: number) {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
|
const items = this.optionItems();
|
||||||
|
if (items[index]) {
|
||||||
|
items[index].nativeElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
normalizedOptions = computed(() => {
|
normalizedOptions = computed(() => {
|
||||||
return this.options().map(opt => {
|
return this.options().map((opt) => {
|
||||||
if (typeof opt === 'string') {
|
if (typeof opt === 'string') {
|
||||||
return { label: opt, value: opt };
|
return { label: opt, value: opt };
|
||||||
}
|
}
|
||||||
@@ -55,15 +142,78 @@ export class Dropdown {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
filteredOptions = computed(() => {
|
||||||
|
const query = this.searchQuery().toLowerCase().trim().normalize('NFKD');
|
||||||
|
const opts = this.normalizedOptions();
|
||||||
|
|
||||||
|
if (!this.inputFocusMode()) return opts;
|
||||||
|
if (!query) return opts;
|
||||||
|
|
||||||
|
return opts.filter((o) => o.label.toLowerCase().normalize('NFKD').includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.isOpen.update(v => !v);
|
if (this.isLatestMouseDownInsideInput) {
|
||||||
|
this.isLatestMouseDownInsideInput = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
this.focusedIndex.set(this.normalizedOptions().findIndex(o => o.value === this.value()));
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
let wasFocusVisible = this.element.nativeElement.matches(':focus-visible');
|
||||||
|
this.focusSearchInput();
|
||||||
|
|
||||||
|
if (wasFocusVisible) this.inputFocusMode.set(true);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onInputFocusIn() {
|
||||||
|
if (this.isOpen()) this.element.nativeElement.tabIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputFocusOut() {
|
||||||
|
if (this.isOpen()) this.element.nativeElement.tabIndex = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusIn(event: FocusEvent) {
|
||||||
|
if (this.element.nativeElement.contains(event.relatedTarget)) return;
|
||||||
|
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.focusSearchInput();
|
||||||
|
this.inputFocusMode.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusSearchInput() {
|
||||||
|
const searchInputElement = this.searchInput()!.nativeElement;
|
||||||
|
|
||||||
|
searchInputElement.focus();
|
||||||
|
this.setCursorToEnd(searchInputElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkIfInputFocus(event: Event) {
|
||||||
|
let triggerInputFocus = this.searchInput()!.nativeElement.contains(
|
||||||
|
event.target! as HTMLElement,
|
||||||
|
);
|
||||||
|
return this.isOpen() && triggerInputFocus;
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
|
||||||
|
const isDropdownOpen = this.isOpen();
|
||||||
|
|
||||||
this.isOpen.set(false);
|
this.isOpen.set(false);
|
||||||
|
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
|
||||||
|
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
|
||||||
|
|
||||||
|
this.element.nativeElement.tabIndex = undefined;
|
||||||
this.focusedIndex.set(-1);
|
this.focusedIndex.set(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,63 +223,150 @@ export class Dropdown {
|
|||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnter(event: Event) {
|
onConfirm(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
const currentIndex = this.focusedIndex();
|
const currentIndex = this.focusedIndex();
|
||||||
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
const options = this.filteredOptions();
|
||||||
this.selectOption(this.normalizedOptions()[currentIndex]);
|
if (currentIndex >= 0 && currentIndex < options.length) {
|
||||||
|
this.selectOption(options[currentIndex]);
|
||||||
|
} else if (options.length === 0) {
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEnter(event: Event) {
|
||||||
|
this.onConfirm(event);
|
||||||
|
}
|
||||||
|
|
||||||
onSpaceDown(event: Event) {
|
onSpaceDown(event: Event) {
|
||||||
event.preventDefault();
|
if (this.isOpen() && this.inputFocusMode()) return;
|
||||||
if (this.isOpen()) {
|
this.onConfirm(event);
|
||||||
const currentIndex = this.focusedIndex();
|
|
||||||
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
|
||||||
this.selectOption(this.normalizedOptions()[currentIndex]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.toggle();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onArrowDown(event: Event) {
|
onArrowDown(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.focusedIndex.update(i => {
|
|
||||||
|
this.focusedIndex.update((i) => {
|
||||||
|
const options = this.filteredOptions();
|
||||||
|
if (options.length === 0) return -1;
|
||||||
|
|
||||||
if (i === -1) return 0;
|
if (i === -1) return 0;
|
||||||
return (i + 1) % this.normalizedOptions().length;
|
return (i + 1) % options.length;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onArrowUp(event: Event) {
|
onArrowUp(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.focusedIndex.update(i => {
|
|
||||||
if (i <= 0) return this.normalizedOptions().length - 1;
|
this.focusedIndex.update((i) => {
|
||||||
|
const options = this.filteredOptions();
|
||||||
|
if (options.length === 0) return -1;
|
||||||
|
|
||||||
|
if (i <= 0) return options.length - 1;
|
||||||
return i - 1;
|
return i - 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
onKeyDown(event: KeyboardEvent) {
|
||||||
onDocumentClick(event: MouseEvent) {
|
const textArrowKeys = ['ArrowLeft', 'ArrowRight'];
|
||||||
if (!this.el.nativeElement.contains(event.target)) {
|
|
||||||
this.close();
|
if (!textArrowKeys.includes(event.key)) return;
|
||||||
|
if (!this.isOpen() || this.inputFocusMode()) return;
|
||||||
|
|
||||||
|
this.inputFocusMode.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputMouseDown() {
|
||||||
|
this.isLatestMouseDownInsideInput = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseDown(event: MouseEvent) {
|
||||||
|
this.isLatestMouseDownInsideContainer = this.element.nativeElement.contains(event.target);
|
||||||
|
|
||||||
|
if (this.checkIfInputFocus(event)) {
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocumentClick(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
!this.element.nativeElement.contains(event.target) &&
|
||||||
|
!this.isLatestMouseDownInsideContainer &&
|
||||||
|
this.isOpen()
|
||||||
|
) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLatestMouseDownInsideInput = false;
|
||||||
|
this.isLatestMouseDownInsideContainer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputClick(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.isLatestMouseDownInsideInput = false;
|
||||||
|
this.isLatestMouseDownInsideContainer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearch(event: Event) {
|
||||||
|
if (!this.isOpen()) return;
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
|
const element = event.target as HTMLElement;
|
||||||
|
const text = element.innerText;
|
||||||
|
|
||||||
|
if (text.trim().length <= 0) element.innerHTML = '';
|
||||||
|
this.focusedIndex.set(0);
|
||||||
|
this.searchQuery.set(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
preprocessInputContent(event: InputEvent) {
|
||||||
|
if (event.inputType === 'insertLineBreak' || event.inputType === 'insertParagraph') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.inputType === 'insertFromPaste') {
|
||||||
|
const originalText = event.dataTransfer!.getData('text/plain');
|
||||||
|
const modifiedText = originalText.replace(/[\r\n]+/g, '');
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// noinspection JSDeprecatedSymbols
|
||||||
|
document.execCommand('insertText', false, modifiedText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedOption = computed(() => {
|
||||||
|
return this.normalizedOptions().find((o) => o.value === this.value());
|
||||||
|
})
|
||||||
|
|
||||||
selectedLabel = computed(() => {
|
selectedLabel = computed(() => {
|
||||||
const selected = this.normalizedOptions().find(o => o.value === this.value());
|
const selected = this.selectedOption();
|
||||||
return selected ? selected.label : this.placeholder();
|
return selected ? selected.label : this.placeholder();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private setCursorToEnd(element: HTMLElement) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
range.collapse(false);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<section class="information">
|
||||||
|
<div class="soundtrack">
|
||||||
|
<span class="artist">{{ artist() }}</span>
|
||||||
|
<span class="track-name">{{ trackName() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="other">
|
||||||
|
<div class="creator">
|
||||||
|
<span>by</span>
|
||||||
|
<span>{{ creatorString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="buttons">
|
||||||
|
<button [aria-label]="previewButtonLabel()">
|
||||||
|
<div class="material-symbols-outlined icon-40 button-icon">play_arrow</div>
|
||||||
|
</button>
|
||||||
|
<button [aria-label]="downloadButtonLabel()">
|
||||||
|
<div class="material-symbols-outlined icon-40 button-icon">download</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$default_animation_duration: 0.2s;
|
||||||
|
|
||||||
|
@property --hover-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
height: 160px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
||||||
|
// overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline-offset: 0;
|
||||||
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.information {
|
||||||
|
width: calc(100% - 64px);
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
z-index: 1;
|
||||||
|
background-color: light-dark(white, black);
|
||||||
|
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
// outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 1px solid;
|
||||||
|
|
||||||
|
.soundtrack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 20px;
|
||||||
|
align-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 22px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.other {
|
||||||
|
display: flex;
|
||||||
|
width: 312px;
|
||||||
|
height: 68px;
|
||||||
|
padding: 3px 0;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.creator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 20px;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
align-content: center;
|
||||||
|
&:nth-child(2) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-self: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
border: none;
|
||||||
|
transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing;
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklch,
|
||||||
|
light-dark(oklch(1 0 0), oklch(0 0 0)),
|
||||||
|
light-dark(oklch(0.9702 0 0), oklch(0.25 0 0))
|
||||||
|
calc(var(--hover-state) * 100%)
|
||||||
|
);
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
&:hover {
|
||||||
|
--hover-state: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:focus-visible) {
|
||||||
|
box-shadow: -1px 0 0 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
z-index: 11;
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.button-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'level-card',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './level-card.html',
|
||||||
|
styleUrl: './level-card.scss',
|
||||||
|
host: {
|
||||||
|
role: 'article',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class LevelCard {
|
||||||
|
private translationSvc = inject(TranslationService);
|
||||||
|
protected translation = this.translationSvc.translation;
|
||||||
|
|
||||||
|
artist = input.required<string>();
|
||||||
|
trackName = input.required<string>();
|
||||||
|
creator = input<string[]>([]);
|
||||||
|
|
||||||
|
creatorString = computed(() => this.creator().join(' & '));
|
||||||
|
|
||||||
|
previewButtonLabel = computed(() =>
|
||||||
|
this.translationSvc.interpolate(this.translation.levelCard.preview, {
|
||||||
|
artist: this.artist(),
|
||||||
|
trackName: this.trackName(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
downloadButtonLabel = computed(() =>
|
||||||
|
this.translationSvc.interpolate(this.translation.levelCard.download, {
|
||||||
|
creator: this.creatorString()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()">
|
||||||
|
<div #popupContainer class="popup-container"
|
||||||
|
[class]="'variant-' + variant()"
|
||||||
|
[class.custom-pos]="!isCenter"
|
||||||
|
[style.top]="customStyle.top"
|
||||||
|
[style.left]="customStyle.left"
|
||||||
|
[style.right]="customStyle.right"
|
||||||
|
[style.bottom]="customStyle.bottom">
|
||||||
|
<header class="popup-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<ng-content select="[header-left]"></ng-content>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()" [aria-label]="closeButtonLabel()">close</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="popup-body">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
$animation_timing: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
$default_animation_duration: 0.2s;
|
||||||
|
|
||||||
|
@property --close-hover-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-container {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 40px oklch(0 0 0 / 15%);
|
||||||
|
min-width: 40px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
animation: popup-enter var(--animation-duration) $animation_timing;
|
||||||
|
|
||||||
|
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&.variant-glass {
|
||||||
|
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variant-solid {
|
||||||
|
background-color: white;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.custom-pos {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline-offset: 0;
|
||||||
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popup-enter {
|
||||||
|
from {
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 40px;
|
||||||
|
box-shadow: 0 1px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
.close-btn {
|
||||||
|
--close-hover-state: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background: color-mix(
|
||||||
|
in oklch,
|
||||||
|
transparent,
|
||||||
|
light-dark(oklch(0 0 0 / 5%), oklch(1 0 0 / 5%))
|
||||||
|
calc(var(--close-hover-state) * 100%)
|
||||||
|
);
|
||||||
|
|
||||||
|
color: color-mix(
|
||||||
|
in oklch,
|
||||||
|
light-dark(oklch(0.4 0 0), oklch(0.6 0 0)),
|
||||||
|
light-dark(oklch(0 0 0), oklch(1 0 0))
|
||||||
|
calc(var(--close-hover-state) * 100%)
|
||||||
|
);
|
||||||
|
transition: --close-hover-state 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--close-hover-state: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
padding: 0 12px;
|
||||||
|
overflow-y: visible;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
HostListener,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
viewChild,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
|
export type PopupPosition = {
|
||||||
|
top?: string;
|
||||||
|
left?: string;
|
||||||
|
right?: string;
|
||||||
|
bottom?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PopupVariant = 'glass' | 'solid';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-popup',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './popup.html',
|
||||||
|
styleUrl: './popup.scss',
|
||||||
|
host: {
|
||||||
|
role: 'dialog',
|
||||||
|
'aria-modal': 'true',
|
||||||
|
tabindex: '-1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class PopupComponent implements OnInit, OnDestroy {
|
||||||
|
private translation = inject(TranslationService).translation;
|
||||||
|
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
|
||||||
|
private previouslyFocusedElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업의 위치 (기본값은 'center')
|
||||||
|
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
|
||||||
|
*/
|
||||||
|
position = input<PopupPosition | 'center'>('center');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업의 스타일 변형 (기본값은 'glass')
|
||||||
|
*/
|
||||||
|
variant = input<PopupVariant>('glass');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
|
||||||
|
*/
|
||||||
|
closeButtonLabel = input<string>(this.translation.common.close);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
|
||||||
|
*/
|
||||||
|
closeRequest = output<void>();
|
||||||
|
|
||||||
|
constructor(private element: ElementRef) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
this.previouslyFocusedElement = document.activeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.element.nativeElement.focus();
|
||||||
|
|
||||||
|
this.popupContainer()!.nativeElement.tabIndex = 0;
|
||||||
|
this.popupContainer()!.nativeElement.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
if (
|
||||||
|
this.previouslyFocusedElement &&
|
||||||
|
typeof this.previouslyFocusedElement.focus === 'function'
|
||||||
|
) {
|
||||||
|
this.previouslyFocusedElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('keydown', ['$event'])
|
||||||
|
onKeydownHandler(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Tab') this.handleTabKey(event);
|
||||||
|
if (event.key === 'Escape') this.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTabKey(event: KeyboardEvent) {
|
||||||
|
const focusableElements = this.getOrderedFocusableElements(this.popupContainer()!.nativeElement);
|
||||||
|
|
||||||
|
if (focusableElements.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstElement = focusableElements[0] as HTMLElement;
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
// If Shift + Tab and focus is on the first element (or the container), move to the last element
|
||||||
|
if (
|
||||||
|
activeElement === firstElement ||
|
||||||
|
activeElement === this.popupContainer()!.nativeElement
|
||||||
|
) {
|
||||||
|
lastElement.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If Tab and focus is on the last element, move to the first element
|
||||||
|
if (activeElement === lastElement) {
|
||||||
|
firstElement.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrderedFocusableElements(container: HTMLElement) {
|
||||||
|
const selector = 'button, [href], input, select, textarea, [tabindex]';
|
||||||
|
|
||||||
|
const elements = Array.from(container.querySelectorAll(selector)).filter((el) => {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
|
||||||
|
// 비활성 상태거나 tabindex가 -1이면 제외
|
||||||
|
if ((htmlEl as HTMLInputElement).disabled || el.getAttribute('tabindex') === '-1')
|
||||||
|
return false;
|
||||||
|
// 화면에 보이지 않는 요소 제외 (필요 시 추가)
|
||||||
|
return !!(htmlEl.offsetWidth || htmlEl.offsetHeight || el.getClientRects().length);
|
||||||
|
});
|
||||||
|
|
||||||
|
return elements.sort((a, b) => {
|
||||||
|
const tabA = parseInt(a.getAttribute('tabindex') ?? '0', 10);
|
||||||
|
const tabB = parseInt(b.getAttribute('tabindex') ?? '0', 10);
|
||||||
|
|
||||||
|
// 1. 양수 tabindex 처리 (낮은 숫자 우선)
|
||||||
|
if (tabA > 0 && tabB > 0) return tabA - tabB;
|
||||||
|
if (tabA > 0) return -1;
|
||||||
|
if (tabB > 0) return 1;
|
||||||
|
|
||||||
|
// 2. 나머지는(0 또는 기본값) DOM 순서 유지
|
||||||
|
// querySelectorAll 결과가 이미 DOM 순서이므로 0 반환으로 충분
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.closeRequest.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCenter() {
|
||||||
|
return this.position() === 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
get customStyle() {
|
||||||
|
const pos = this.position();
|
||||||
|
return pos === 'center' ? {} : pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<ng-content></ng-content>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
$default_animation_duration: 0.4s;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
top: -140px;
|
||||||
|
left: 20px;
|
||||||
|
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
|
|
||||||
|
background: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
|
||||||
|
padding: calc(40px + 12px) 24px 12px;
|
||||||
|
z-index: 20000;
|
||||||
|
|
||||||
|
transition: top var(--animation-duration) cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
|
||||||
|
box-shadow: 0 0 40px oklch(0 0 0 / 15%);
|
||||||
|
vertical-align: middle;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
top: -40px;
|
||||||
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
|
||||||
|
::ng-deep * {
|
||||||
|
// 내부에서 탭 이동 시에는 지연 없이 즉시 전환되도록 합니다.
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep * {
|
||||||
|
appearance: none;
|
||||||
|
color: light-dark(black, white);
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
min-width: unset;
|
||||||
|
min-height: unset;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 0;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Component에서 포커스가 나갈 때(애니메이션 중) 마지막 포커스된 요소가 유지되도록 지연 시간을 둡니다.
|
||||||
|
transition: opacity 0s var(--animation-duration), width 0s var(--animation-duration);
|
||||||
|
interpolate-size: allow-keywords;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
width: auto;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'skip-links',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './skip-links.html',
|
||||||
|
styleUrl: './skip-links.scss',
|
||||||
|
host: {
|
||||||
|
'tabindex': '-1',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export class SkipLinks {}
|
||||||
@@ -2,35 +2,53 @@
|
|||||||
@use "sass:list";
|
@use "sass:list";
|
||||||
@use "styles/library" as library;
|
@use "styles/library" as library;
|
||||||
|
|
||||||
$switch_on_bg: oklch(0.7086 0.1896 39.86); // #FF6E37
|
$off_bg: oklch(0.683 0 0); // #999999
|
||||||
$switch_off_bg: oklch(0.683 0 0); // #999999
|
$off_dark_bg: oklch(0 0 0);
|
||||||
|
|
||||||
$switch_dimension: (57px, 30px);
|
$dimension: (57px, 30px);
|
||||||
$switch_slider_dimension: (24px, 24px);
|
$knob_dimension: (24px, 24px);
|
||||||
$switch_padding: (3px, 3px);
|
$internal_padding: (3px, 3px);
|
||||||
$switch_border_radius: 15px;
|
|
||||||
$switch_border_color: oklch(0 0 0 / 20%);
|
|
||||||
|
|
||||||
$switch_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
$border_radius: 15px;
|
||||||
$switch_animation_duration: 0.3s;
|
$border_color: oklch(0 0 0 / 20%);
|
||||||
|
$border_dark_color: oklch(1 0 0 / 40%);
|
||||||
|
|
||||||
|
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$default_animation_duration: 0.2s;
|
||||||
|
|
||||||
|
@property --animation-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: list.nth($switch_dimension, 1); height: list.nth($switch_dimension, 2);
|
width: list.nth($dimension, 1); height: list.nth($dimension, 2);
|
||||||
border-radius: $switch_border_radius;
|
border-radius: $border_radius;
|
||||||
background-color: $switch_off_bg;
|
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklch,
|
||||||
|
light-dark($off_bg, $off_dark_bg),
|
||||||
|
light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6))
|
||||||
|
calc(var(--animation-state) * 100%)
|
||||||
|
);
|
||||||
|
|
||||||
|
box-shadow: 0 0 0 1px color-mix(
|
||||||
|
in oklch,
|
||||||
|
light-dark($border_color, $border_dark_color),
|
||||||
|
light-dark($border_color, list.nth(library.$brand_palette, 9))
|
||||||
|
calc(var(--animation-state) * 100%)
|
||||||
|
) inset;
|
||||||
|
|
||||||
|
--animation-state: 0;
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
|
// transition: background-color var(--animation-duration) $animation_timing, box-shadow var(--animation-duration) $animation_timing;
|
||||||
|
transition: --animation-state var(--animation-duration) $animation_timing;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 0 0 1px $switch_border_color inset;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
--switch-duration: #{$switch_animation_duration};
|
|
||||||
transition: background-color var(--switch-duration) $switch_animation_timing;
|
|
||||||
|
|
||||||
&.reduce-motion {
|
|
||||||
--switch-duration: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover, &:focus-within {
|
&:hover, &:focus-within {
|
||||||
.switch-knob {
|
.switch-knob {
|
||||||
@@ -38,17 +56,13 @@ $switch_animation_duration: 0.3s;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
box-shadow: 0 0 0 1px $switch_border_color inset, 0 0 0 2px white, 0 0 0 4px $switch_on_bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: $switch_on_bg;
|
--animation-state: 1;
|
||||||
|
|
||||||
.switch-knob {
|
.switch-knob {
|
||||||
transform: translate(
|
transform: translate(
|
||||||
list.nth($switch_dimension, 1) - list.nth($switch_slider_dimension, 1) - list.nth($switch_padding, 1) * 2,
|
list.nth($dimension, 1) - list.nth($knob_dimension, 1) - list.nth($internal_padding, 1) * 2,
|
||||||
list.nth($switch_dimension, 2) - list.nth($switch_slider_dimension, 2) - list.nth($switch_padding, 2) * 2
|
list.nth($dimension, 2) - list.nth($knob_dimension, 2) - list.nth($internal_padding, 2) * 2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,10 +70,10 @@ $switch_animation_duration: 0.3s;
|
|||||||
|
|
||||||
.switch-knob {
|
.switch-knob {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: list.nth($switch_slider_dimension, 1); height: list.nth($switch_slider_dimension, 2);
|
width: list.nth($knob_dimension, 1); height: list.nth($knob_dimension, 2);
|
||||||
left: list.nth($switch_padding, 1); top: list.nth($switch_padding, 2);
|
left: list.nth($internal_padding, 1); top: list.nth($internal_padding, 2);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: transform var(--switch-duration) $switch_animation_timing;
|
transition: transform var(--animation-duration) $animation_timing;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, model, output } from '@angular/core';
|
import { Component, model, output } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'switch',
|
selector: 'switch',
|
||||||
@@ -10,7 +10,6 @@ import { Component, input, model, output } from '@angular/core';
|
|||||||
tabindex: '0',
|
tabindex: '0',
|
||||||
'[attr.aria-checked]': 'isActive()',
|
'[attr.aria-checked]': 'isActive()',
|
||||||
'[class.active]': 'isActive()',
|
'[class.active]': 'isActive()',
|
||||||
'[class.reduce-motion]': 'reduceMotion()',
|
|
||||||
'(click)': 'onClick()',
|
'(click)': 'onClick()',
|
||||||
'(keydown.enter)': 'onClick()',
|
'(keydown.enter)': 'onClick()',
|
||||||
'(keydown.space)': 'onSpaceDown($event)',
|
'(keydown.space)': 'onSpaceDown($event)',
|
||||||
@@ -18,11 +17,6 @@ import { Component, input, model, output } from '@angular/core';
|
|||||||
})
|
})
|
||||||
export class Switch {
|
export class Switch {
|
||||||
isActive = model<boolean>(false);
|
isActive = model<boolean>(false);
|
||||||
reduceMotion = input<boolean>(
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
||||||
: false,
|
|
||||||
);
|
|
||||||
toggle = output<boolean>();
|
toggle = output<boolean>();
|
||||||
|
|
||||||
onClick(): void {
|
onClick(): void {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div #menuLeft class="menu-left">
|
||||||
|
<a class="brand" [routerLink]="['/']">
|
||||||
|
<img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
|
||||||
|
<span #brandText [class.hidden]="expandStage() < 2">
|
||||||
|
<strong>ADOFAI Editor Forum</strong>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div #leftMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
|
||||||
|
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div #menuRight class="menu-right">
|
||||||
|
<div #rightMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
|
||||||
|
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
|
||||||
|
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" [class.hidden]="expandStage() > 0" [attr.inert]="expandStage() > 0 ? '' : null" class="material-symbols-outlined icon-40 menu" (click)="onHamburgerClick()" [attr.aria-label]="translation.menu.menu">
|
||||||
|
menu
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
|
||||||
|
settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
@use "sass:map";
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
|
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
|
||||||
|
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
|
||||||
|
contain: layout paint;
|
||||||
|
isolation: isolate;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.menu-left, .menu-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 48px;
|
||||||
|
|
||||||
|
.menu-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 48px;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
top: -100000px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item, a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 18px;
|
||||||
|
height: 40px;
|
||||||
|
align-content: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.nav-link {
|
||||||
|
font-weight: 700;
|
||||||
|
color: oklch(0.6 0.15 250);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dummy-link {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings, .menu {
|
||||||
|
border: none;
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: rotate(30deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
top: -100000px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 구체적인 아이콘 예외 처리 */
|
||||||
|
.icon-40 {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-left {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
justify-content: start;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: inline-block;
|
||||||
|
height: 20px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 900 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
padding-left: 10px;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
top: -100000px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-right {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 20px 12px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
NgZone,
|
||||||
|
OnDestroy,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { PopupService } from '../../services/popup.service';
|
||||||
|
import { SettingsPopup } from '../settings/settings';
|
||||||
|
import { NavigationMenu } from './navigation-menu';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
import { SettingsService } from '../../services/settings.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'header-menu',
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './header-menu.html',
|
||||||
|
styleUrl: './header-menu.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'[class.folded-menu]': 'expandStage() < 1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class HeaderMenu implements OnDestroy {
|
||||||
|
private popupService = inject(PopupService);
|
||||||
|
protected translation = inject(TranslationService).translation;
|
||||||
|
protected settings = inject(SettingsService);
|
||||||
|
|
||||||
|
private static readonly LOGICAL_GAP = 24;
|
||||||
|
|
||||||
|
private resizeObserver?: ResizeObserver;
|
||||||
|
private brandTextObserver?: ResizeObserver;
|
||||||
|
private menuItemsObserver?: ResizeObserver;
|
||||||
|
private menuWidth: number = 99999;
|
||||||
|
|
||||||
|
private menuLeft = viewChild<ElementRef<HTMLElement>>('menuLeft');
|
||||||
|
private leftMenuItems = viewChild<ElementRef<HTMLElement>>('leftMenuItems');
|
||||||
|
private menuLeftWidth: number = 0;
|
||||||
|
private leftMenuItemsWidth: number = 0;
|
||||||
|
|
||||||
|
private menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
|
||||||
|
private rightMenuItems = viewChild<ElementRef<HTMLElement>>('rightMenuItems');
|
||||||
|
private menuRightWidth: number = 0;
|
||||||
|
private rightMenuItemsWidth: number = 0;
|
||||||
|
|
||||||
|
private brandText = viewChild<ElementRef<HTMLElement>>('brandText');
|
||||||
|
private brandTextWidth: number = 0;
|
||||||
|
|
||||||
|
expandStage = signal(2);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private element: ElementRef,
|
||||||
|
private ngZone: NgZone,
|
||||||
|
) {
|
||||||
|
effect(() => {
|
||||||
|
if (this.expandStage() > 0) {
|
||||||
|
this.popupService.close(NavigationMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNextRender(() => {
|
||||||
|
this.ngZone.runOutsideAngular(() => {
|
||||||
|
this.resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
const width = entry.borderBoxSize[0].inlineSize;
|
||||||
|
if (width > 0)
|
||||||
|
switch (entry.target) {
|
||||||
|
case element.nativeElement:
|
||||||
|
this.menuWidth = width;
|
||||||
|
break;
|
||||||
|
case this.menuLeft()!.nativeElement:
|
||||||
|
this.menuLeftWidth = width;
|
||||||
|
break;
|
||||||
|
case this.menuRight()!.nativeElement:
|
||||||
|
this.menuRightWidth = width;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateExpandStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.menuItemsObserver = new ResizeObserver((entries) => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
const width = entry.borderBoxSize[0].inlineSize;
|
||||||
|
if (width > 0)
|
||||||
|
switch (entry.target) {
|
||||||
|
case this.leftMenuItems()!.nativeElement:
|
||||||
|
this.leftMenuItemsWidth = width;
|
||||||
|
break;
|
||||||
|
case this.rightMenuItems()!.nativeElement:
|
||||||
|
this.rightMenuItemsWidth = width;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateExpandStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.resizeObserver.observe(element.nativeElement);
|
||||||
|
this.resizeObserver.observe(this.menuLeft()!.nativeElement);
|
||||||
|
this.resizeObserver.observe(this.menuRight()!.nativeElement);
|
||||||
|
|
||||||
|
this.menuItemsObserver.observe(this.leftMenuItems()!.nativeElement);
|
||||||
|
this.menuItemsObserver.observe(this.rightMenuItems()!.nativeElement);
|
||||||
|
|
||||||
|
this.brandTextObserver = new ResizeObserver((entries) => {
|
||||||
|
const width = entries[0].borderBoxSize[0].inlineSize;
|
||||||
|
if (width > 0) this.brandTextWidth = width;
|
||||||
|
|
||||||
|
this.updateExpandStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.brandTextObserver.observe(this.brandText()!.nativeElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
this.menuItemsObserver?.disconnect();
|
||||||
|
this.brandTextObserver?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateExpandStatus() {
|
||||||
|
let menuContentWidth = this.menuLeftWidth + this.menuRightWidth;
|
||||||
|
let enoughSpace = this.menuWidth > menuContentWidth + HeaderMenu.LOGICAL_GAP;
|
||||||
|
let initialExpandStage = this.expandStage();
|
||||||
|
|
||||||
|
if (enoughSpace) {
|
||||||
|
let extraSpaceToExpand = Number.MAX_VALUE;
|
||||||
|
switch (initialExpandStage) {
|
||||||
|
case 1:
|
||||||
|
extraSpaceToExpand = this.brandTextWidth;
|
||||||
|
break;
|
||||||
|
case 0:
|
||||||
|
extraSpaceToExpand = this.leftMenuItemsWidth + this.rightMenuItemsWidth + 48 - 40;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
// Add a gap of 2px to prevent flickering
|
||||||
|
this.menuWidth >= menuContentWidth + HeaderMenu.LOGICAL_GAP + extraSpaceToExpand + 2
|
||||||
|
) {
|
||||||
|
this.expandStage.set(initialExpandStage + 1);
|
||||||
|
}
|
||||||
|
} else if (initialExpandStage > 0) this.expandStage.set(initialExpandStage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
onHamburgerClick(): void {
|
||||||
|
this.popupService.open(NavigationMenu, {
|
||||||
|
position: { top: '84px', right: '20px' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSettingsClick() {
|
||||||
|
this.popupService.open(SettingsPopup, { position: { top: '84px', right: '20px' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<app-popup [position]="position()" (closeRequest)="close()">
|
||||||
|
<div header-left class="menu-header-title">
|
||||||
|
<span class="material-symbols-outlined">menu</span>
|
||||||
|
<span>{{ translation.menu.menu }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-content">
|
||||||
|
<div class="menu-section">
|
||||||
|
<a class="menu-item nav-link" [routerLink]="['/levels']" (click)="close()">{{ translation.menu.levels }}</a>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="menu-divider" />
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<a class="menu-item nav-link" [routerLink]="['/known-issues']" (click)="close()">{{ translation.menu.knownIssues }}</a>
|
||||||
|
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-popup>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 276px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
border: none;
|
||||||
|
background-color: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-content: center;
|
||||||
|
height: 40px;
|
||||||
|
padding-left: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-size: 24px;
|
||||||
|
color: light-dark(oklch(0.4 0 0), oklch(0.8 0 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nav-link {
|
||||||
|
font-weight: 700;
|
||||||
|
color: oklch(0.6 0.15 250);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dummy-link {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, inject, input } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
|
||||||
|
import { PopupService } from '../../services/popup.service';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'navigation-menu',
|
||||||
|
standalone: true,
|
||||||
|
imports: [PopupComponent, RouterLink],
|
||||||
|
templateUrl: './navigation-menu.html',
|
||||||
|
styleUrl: './navigation-menu.scss',
|
||||||
|
})
|
||||||
|
export class NavigationMenu {
|
||||||
|
private popupService = inject(PopupService);
|
||||||
|
protected translation = inject(TranslationService).translation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업의 위치
|
||||||
|
*/
|
||||||
|
position = input<PopupPosition | 'center'>('center');
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.popupService.close(NavigationMenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<label class="category-header">{{ headerLabel() }}</label>
|
||||||
|
<ng-content></ng-content>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 15px;
|
||||||
|
// color: oklch(0.6830 0 0); // #999999
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@if (settingsType() == SettingsType.Switch) {
|
||||||
|
<div class="settings-name">
|
||||||
|
@if (labelIcon()?.trim()?.length ?? 0 > 0) {
|
||||||
|
<span class="material-symbols-outlined icon-24 settings-icon" aria-hidden="true">{{ labelIcon() }}</span>
|
||||||
|
}
|
||||||
|
<label [id]="labelID">{{ labelText() }}</label>
|
||||||
|
</div>
|
||||||
|
<switch [isActive]="value()" (toggle)="onChange.emit($event)" [aria-labelledby]="labelID"></switch>
|
||||||
|
} @else if (settingsType() == SettingsType.Dropdown) {
|
||||||
|
<label [id]="labelID" class="hidden-label">{{ labelText() }}</label>
|
||||||
|
<dropdown [options]="options()"
|
||||||
|
[value]="value()"
|
||||||
|
(selectionChange)="onChange.emit($event)"
|
||||||
|
[aria-labelledby]="labelID"
|
||||||
|
[placeholder]="placeholder()?.trim() ?? ''"
|
||||||
|
[icon]="labelIcon()"></dropdown>
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&:has(> switch) {
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
&:nth-child(1 of .settings-container) {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(> dropdown) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.settings-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span, label {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
&.settings-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
/*
|
||||||
|
line-height: 24px;
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown {
|
||||||
|
width: 324px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-label {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<app-popup (closeRequest)="close()" [position]="position()">
|
||||||
|
<div header-left class="profile-section">
|
||||||
|
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
|
||||||
|
<span>{{ settingsService.isGuest() ? translation.settings.guest : settingsService.userName() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<settings-category [headerLabel]="translation.settings.language">
|
||||||
|
<settings-container
|
||||||
|
[settingsType]="SettingsType.Dropdown"
|
||||||
|
[options]="languages"
|
||||||
|
[value]="settingsService.language()"
|
||||||
|
[labelText]="translation.settings.language" labelIcon="language"
|
||||||
|
(onChange)="onLanguageChange($event)">
|
||||||
|
</settings-container>
|
||||||
|
|
||||||
|
<settings-container
|
||||||
|
[settingsType]="SettingsType.Switch"
|
||||||
|
[value]="settingsService.romanisation()"
|
||||||
|
[labelText]="translation.settings.romanisation" labelIcon="match_case"
|
||||||
|
(onChange)="onRomanisationChange($event)">
|
||||||
|
</settings-container>
|
||||||
|
</settings-category>
|
||||||
|
|
||||||
|
<settings-category [headerLabel]="translation.settings.appearance">
|
||||||
|
<settings-container
|
||||||
|
[settingsType]="SettingsType.Switch"
|
||||||
|
[value]="settingsService.darkMode()"
|
||||||
|
[labelText]="translation.settings.darkMode" labelIcon="dark_mode"
|
||||||
|
(onChange)="onDarkModeChange($event)">
|
||||||
|
</settings-container>
|
||||||
|
|
||||||
|
<settings-container
|
||||||
|
[settingsType]="SettingsType.Switch"
|
||||||
|
[value]="settingsService.reduceAnimation()"
|
||||||
|
[labelText]="translation.settings.reduceAnimation" labelIcon="motion_photos_paused"
|
||||||
|
(onChange)="onReduceAnimationChange($event)">
|
||||||
|
</settings-container>
|
||||||
|
</settings-category>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</app-popup>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
outline: 1px solid oklch(0 0 0 / 20%);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Component, inject, input, output } from '@angular/core';
|
||||||
|
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export enum SettingsType {
|
||||||
|
Switch = 'switch', Dropdown = 'dropdown',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'settings-category',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './category.html',
|
||||||
|
styleUrl: './category.scss',
|
||||||
|
})
|
||||||
|
export class SettingsCategory {
|
||||||
|
headerLabel = input<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'settings-container',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './container.html',
|
||||||
|
styleUrl: './container.scss',
|
||||||
|
imports: [Switch, Dropdown],
|
||||||
|
host: {
|
||||||
|
// For IDE Compatibility
|
||||||
|
'[class.settings-container]': 'true',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class SettingsContainer {
|
||||||
|
settingsType = input.required<SettingsType>();
|
||||||
|
protected readonly SettingsType = SettingsType;
|
||||||
|
|
||||||
|
labelIcon = input<string>();
|
||||||
|
labelText = input.required<string>();
|
||||||
|
|
||||||
|
value = input.required<any>();
|
||||||
|
onChange = output<any>();
|
||||||
|
|
||||||
|
placeholder = input<string>();
|
||||||
|
options = input<any>();
|
||||||
|
|
||||||
|
private static nextID = 0;
|
||||||
|
labelID = `settings-${SettingsContainer.nextID++}-label`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'settings-popup',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
PopupComponent,
|
||||||
|
NgOptimizedImage,
|
||||||
|
SettingsCategory,
|
||||||
|
SettingsContainer,
|
||||||
|
],
|
||||||
|
templateUrl: './settings.html',
|
||||||
|
styleUrl: './settings.scss',
|
||||||
|
})
|
||||||
|
export class SettingsPopup {
|
||||||
|
private popupService = inject(PopupService);
|
||||||
|
protected settingsService = inject(SettingsService);
|
||||||
|
protected translation = inject(TranslationService).translation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업의 위치 (기본값은 'center')
|
||||||
|
*/
|
||||||
|
position = input<PopupPosition | 'center'>('center');
|
||||||
|
|
||||||
|
languages: DropdownOption[] = [
|
||||||
|
{ label: 'English', value: 'EN' },
|
||||||
|
{ label: '한국어', value: 'KR' },
|
||||||
|
{ label: '日本語', value: 'JP' },
|
||||||
|
];
|
||||||
|
|
||||||
|
onLanguageChange(lang: Language) {
|
||||||
|
this.settingsService.setLanguage(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDarkModeChange(isDark: boolean) {
|
||||||
|
this.settingsService.setDarkMode(isDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRomanisationChange(value: boolean) {
|
||||||
|
this.settingsService.setRomanisation(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onReduceAnimationChange(value: boolean) {
|
||||||
|
this.settingsService.setReduceAnimation(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
this.popupService.close(SettingsPopup);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly SettingsType = SettingsType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const EN = {
|
||||||
|
settings: {
|
||||||
|
title: 'Settings',
|
||||||
|
guest: 'Guest',
|
||||||
|
language: 'Language',
|
||||||
|
appearance: 'Appearance',
|
||||||
|
darkMode: 'Dark Mode',
|
||||||
|
romanisation: 'Romanisation',
|
||||||
|
reduceAnimation: 'Reduce Animation',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
levels: 'Levels',
|
||||||
|
community: 'Community',
|
||||||
|
leaderboard: 'Leaderboard',
|
||||||
|
about: 'About',
|
||||||
|
knownIssues: 'Known Issues',
|
||||||
|
login: 'Login',
|
||||||
|
signup: 'Signup',
|
||||||
|
menu: 'Menu',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
selectOption: 'Select Option...',
|
||||||
|
close: 'Close',
|
||||||
|
save: 'Save',
|
||||||
|
skipToContent: 'Skip to main content',
|
||||||
|
},
|
||||||
|
levelCard: {
|
||||||
|
preview: '{{trackName}} by {{artist}} Preview',
|
||||||
|
download: "{{creator}}'s Level Download",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Translation = typeof EN;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Translation } from './en';
|
||||||
|
|
||||||
|
export const JP: Translation = {
|
||||||
|
settings: {
|
||||||
|
title: '設定',
|
||||||
|
guest: 'ゲスト',
|
||||||
|
language: '言語',
|
||||||
|
appearance: '外観',
|
||||||
|
darkMode: 'ダークモード',
|
||||||
|
romanisation: 'ローマ字表記',
|
||||||
|
reduceAnimation: 'アニメーションを減らす',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
levels: 'レベル',
|
||||||
|
community: 'コミュニティ',
|
||||||
|
leaderboard: 'リーダーボード',
|
||||||
|
about: '情報',
|
||||||
|
knownIssues: '既知の問題',
|
||||||
|
login: 'ログイン',
|
||||||
|
signup: '新規登録',
|
||||||
|
menu: 'メニュー',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
selectOption: 'オプションを選択...',
|
||||||
|
close: '閉じる',
|
||||||
|
save: '保存',
|
||||||
|
skipToContent: '本文へスキップ',
|
||||||
|
},
|
||||||
|
levelCard: {
|
||||||
|
preview: '{{artist}}の{{trackName}} プレビュー',
|
||||||
|
download: '{{creator}}のレベルをダウンロード',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Translation } from './en';
|
||||||
|
|
||||||
|
export const KR: Translation = {
|
||||||
|
settings: {
|
||||||
|
title: '설정',
|
||||||
|
guest: '게스트',
|
||||||
|
language: '언어',
|
||||||
|
appearance: '화면 설정',
|
||||||
|
darkMode: '다크 모드',
|
||||||
|
romanisation: '로마자 표기',
|
||||||
|
reduceAnimation: '애니메이션 줄이기',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
levels: '레벨',
|
||||||
|
community: '커뮤니티',
|
||||||
|
leaderboard: '리더보드',
|
||||||
|
about: '정보',
|
||||||
|
knownIssues: '알려진 문제',
|
||||||
|
login: '로그인',
|
||||||
|
signup: '회원가입',
|
||||||
|
menu: '메뉴',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
selectOption: '옵션 선택...',
|
||||||
|
close: '닫기',
|
||||||
|
save: '저장',
|
||||||
|
skipToContent: '본문으로 건너뛰기',
|
||||||
|
},
|
||||||
|
levelCard: {
|
||||||
|
preview: '{{artist}}의 {{trackName}} 미리듣기',
|
||||||
|
download: '{{creator}}의 레벨 다운로드',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1>Clock Component Test</h1>
|
||||||
|
|
||||||
|
<section id="clock">
|
||||||
|
<clock></clock>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p style="text-align: center;">
|
||||||
|
<a routerLink="/" style="color: oklch(0.5103 0 0); /* #666 */ text-decoration: none;">Go to Main</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#clock {
|
||||||
|
font-family: "Wanted Sans Variable", sans-serif !important;
|
||||||
|
font-feature-settings: "tnum", "ss06";
|
||||||
|
|
||||||
|
padding-top: 72px;
|
||||||
|
padding-bottom: 72px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { Clock } from '../../components/clock/clock';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-clock-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Clock, RouterLink],
|
||||||
|
templateUrl: './clock.html',
|
||||||
|
styleUrl: './clock.scss',
|
||||||
|
})
|
||||||
|
export class ClockPage {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1>Known Issues</h1>
|
||||||
|
|
||||||
|
<section class="issue-item feature-block">
|
||||||
|
<h2>Issue #1: Dropdown Test UI Layout</h2>
|
||||||
|
<p>Dropdown Component를 테스트하기 위한 UI에서, 상위 컨테이너의 크기가 충분하지 않은 경우 드롭다운이 상위 컨테이너 바깥의 공간을 침범하는 문제가 발생했습니다.</p>
|
||||||
|
<div class="status-tag resolved">Resolved</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="issue-item feature-block">
|
||||||
|
<h2>Issue #2: Dropdown Animation Control</h2>
|
||||||
|
<p>Dropdown Component의 애니메이션 효과가 운영체제의 애니메이션 설정에 영향을 받지 않는 문제가 발생했습니다.</p>
|
||||||
|
<div class="status-tag resolved">Resolved</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
:host > .container {
|
||||||
|
display: flex;
|
||||||
|
width: 600px;
|
||||||
|
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
p {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
&.reported {
|
||||||
|
background-color: oklch(0.786 0.1379 18.69);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wip {
|
||||||
|
background-color: oklch(0.786 0.1379 64.49);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.resolved {
|
||||||
|
background-color: oklch(0.786 0.1379 154.52);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-known-issues',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './known-issues.html',
|
||||||
|
styleUrl: './known-issues.scss'
|
||||||
|
})
|
||||||
|
export class KnownIssuesPage {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<h1>Level Card Test</h1>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<level-card
|
||||||
|
artist="HyuN"
|
||||||
|
trackName="Disorder (Low VFX)"
|
||||||
|
[creator]="['YOON']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="Cinamoro"
|
||||||
|
trackName="Little Rabbit and Deliverance"
|
||||||
|
[creator]="['한가지']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="linear ring"
|
||||||
|
trackName="Dream By Coma"
|
||||||
|
[creator]="['Ryuha']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="Plum"
|
||||||
|
trackName="Champagne Pop"
|
||||||
|
[creator]="['Zizak']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="JinoBeats ft. JinoBeats"
|
||||||
|
trackName="~ Black Forsaken Wings ~"
|
||||||
|
[creator]="['Sakura']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="seatrus"
|
||||||
|
trackName="M A X I M U M B L A S T E R"
|
||||||
|
[creator]="['TraZ']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="MegaSphere"
|
||||||
|
trackName="Inferno"
|
||||||
|
[creator]="['-K']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="Rinne"
|
||||||
|
trackName="Aurelia"
|
||||||
|
[creator]="['토끼로봇']"></level-card>
|
||||||
|
<level-card
|
||||||
|
artist="Sot-C"
|
||||||
|
trackName="Escalation"
|
||||||
|
[creator]="['Chartic']"></level-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@scope(:host) {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host > h1 {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host > .container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 40px;
|
||||||
|
max-width: calc(40px * 2 + 400px * 3);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { Switch } from '../../components/switch/switch';
|
||||||
|
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
|
||||||
|
import { BrandAccent } from '../../components/brand-accent/brand-accent';
|
||||||
|
import { LevelCard } from '../../components/level-card/level-card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-levels',
|
||||||
|
standalone: true,
|
||||||
|
imports: [LevelCard],
|
||||||
|
templateUrl: './levels.html',
|
||||||
|
styleUrl: './levels.scss',
|
||||||
|
})
|
||||||
|
export class LevelPage {
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<brand-accent></brand-accent>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Component Test</h1>
|
||||||
|
|
||||||
|
<section id="switch-test" class="feature-block">
|
||||||
|
<h2>Switch Component</h2>
|
||||||
|
<div class="test-container">
|
||||||
|
<switch [(isActive)]="isToggled" (toggle)="handleToggle($event)"></switch>
|
||||||
|
<p>Switch is: {{ isToggled() ? 'ON' : 'OFF' }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="dropdown-test" class="feature-block">
|
||||||
|
<h2>Dropdown Component</h2>
|
||||||
|
<div class="test-group">
|
||||||
|
<div class="test-container">
|
||||||
|
<h3>Object Options</h3>
|
||||||
|
<dropdown
|
||||||
|
[options]="dropdownOptions"
|
||||||
|
[(value)]="selectedDropdownValue"
|
||||||
|
[defaultIndex]="0"
|
||||||
|
(selectionChange)="handleDropdownChange($event)"
|
||||||
|
icon="language">
|
||||||
|
</dropdown>
|
||||||
|
<p>Selected Value: {{ selectedDropdownValue() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h3>String Options (Simple)</h3>
|
||||||
|
<dropdown
|
||||||
|
[options]="['Small', 'Medium', 'Large']"
|
||||||
|
[(value)]="selectedSize"
|
||||||
|
placeholder="Choose Size">
|
||||||
|
</dropdown>
|
||||||
|
<p>Selected Size: {{ selectedSize() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host > .container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section#dropdown-test {
|
||||||
|
dropdown {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 324px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.test-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 36px;
|
||||||
|
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.test-container {
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
brand-accent {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: -64px;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100dvw;
|
||||||
|
height: calc(100% + 64px);
|
||||||
|
min-height: 100dvh;
|
||||||
|
z-index: -999999;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { Switch } from '../../components/switch/switch';
|
||||||
|
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
|
||||||
|
import { BrandAccent } from '../../components/brand-accent/brand-accent';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-main-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Switch, Dropdown, BrandAccent],
|
||||||
|
templateUrl: './main.html',
|
||||||
|
styleUrl: './main.scss',
|
||||||
|
})
|
||||||
|
export class MainPage {
|
||||||
|
isToggled = signal(false);
|
||||||
|
|
||||||
|
dropdownOptions: DropdownOption[] = [
|
||||||
|
{ label: 'English', value: 'EN' },
|
||||||
|
{ label: '한국어', value: 'KR' },
|
||||||
|
{ label: '日本語', value: 'JP' },
|
||||||
|
{ label: 'Deutsch', value: 'DE' },
|
||||||
|
{ label: 'Italiano', value: 'IT' },
|
||||||
|
{ label: '中文', value: 'CN' },
|
||||||
|
{ label: 'Français', value: 'FR' },
|
||||||
|
{ label: 'Español', value: 'ES' },
|
||||||
|
{ label: 'Português', value: 'PT' },
|
||||||
|
{ label: 'Русский', value: 'RU' },
|
||||||
|
{ label: 'العربية', value: 'AR' },
|
||||||
|
{ label: 'हिन्दी', value: 'HI' },
|
||||||
|
{ label: 'বাংলা', value: 'BN' },
|
||||||
|
{ label: 'Tiếng Việt', value: 'VI' },
|
||||||
|
{ label: 'Türkçe', value: 'TR' },
|
||||||
|
{ label: 'Polski', value: 'PL' },
|
||||||
|
{ label: 'Українська', value: 'UK' },
|
||||||
|
{ label: 'Nederlands', value: 'NL' },
|
||||||
|
{ label: 'Ελληνικά', value: 'EL' },
|
||||||
|
{ label: 'Magyar', value: 'HU' },
|
||||||
|
{ label: 'Čeština', value: 'CS' },
|
||||||
|
{ label: 'Română', value: 'RO' },
|
||||||
|
{ label: 'Svenska', value: 'SV' },
|
||||||
|
{ label: 'Dansk', value: 'DA' },
|
||||||
|
{ label: 'Suomi', value: 'FI' },
|
||||||
|
{ label: 'Norsk', value: 'NO' },
|
||||||
|
{ label: 'ไทย', value: 'TH' },
|
||||||
|
{ label: 'Bahasa Indonesia', value: 'ID' },
|
||||||
|
{ label: 'Bahasa Melayu', value: 'MS' },
|
||||||
|
{ label: 'Filipino', value: 'TL' },
|
||||||
|
{ label: 'עברית', value: 'HE' },
|
||||||
|
{ label: 'فارسی', value: 'FA' },
|
||||||
|
{ label: 'اردو', value: 'UR' },
|
||||||
|
{ label: 'தமிழ்', value: 'TA' },
|
||||||
|
{ label: 'తెలుగు', value: 'TE' },
|
||||||
|
{ label: 'ಕನ್ನಡ', value: 'KN' },
|
||||||
|
{ label: 'മലയാളം', value: 'ML' },
|
||||||
|
{ label: 'मराठी', value: 'MR' },
|
||||||
|
{ label: 'ગુજરાતી', value: 'GU' },
|
||||||
|
{ label: 'ਪੰਜਾਬੀ', value: 'PA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
selectedDropdownValue = signal<any>(null);
|
||||||
|
selectedSize = signal<string | null>(null);
|
||||||
|
|
||||||
|
handleToggle(state: boolean) {
|
||||||
|
console.log('Toggle state:', state);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDropdownChange(value: any) {
|
||||||
|
console.log('Dropdown value:', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable, signal, Type } from '@angular/core';
|
||||||
|
|
||||||
|
export interface PopupInstance {
|
||||||
|
component: Type<any>;
|
||||||
|
inputs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class PopupService {
|
||||||
|
/**
|
||||||
|
* 현재 활성화된 팝업 컴포넌트들의 리스트 (Signal)
|
||||||
|
*/
|
||||||
|
activePopups = signal<PopupInstance[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업을 엽니다. 이미 열려있는 동일한 컴포넌트가 있다면 중복해서 열지 않습니다.
|
||||||
|
*/
|
||||||
|
open<T>(component: Type<T>, inputs?: Record<string, any>) {
|
||||||
|
this.activePopups.update((popups) => {
|
||||||
|
if (popups.some((p) => p.component === component)) return popups;
|
||||||
|
return [...popups, { component, inputs }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 컴포넌트를 닫습니다.
|
||||||
|
*/
|
||||||
|
close(component: Type<any>) {
|
||||||
|
this.activePopups.update((popups) => popups.filter((p) => p.component !== component));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가장 최근에 열린(마지막) 팝업을 닫습니다.
|
||||||
|
*/
|
||||||
|
closeLast() {
|
||||||
|
this.activePopups.update((popups) => popups.slice(0, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 팝업을 닫습니다.
|
||||||
|
*/
|
||||||
|
closeAll() {
|
||||||
|
this.activePopups.set([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Injectable, signal, inject, PLATFORM_ID, computed, REQUEST } from '@angular/core';
|
||||||
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
|
||||||
|
export type Language = 'EN' | 'KR' | 'JP';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class SettingsService {
|
||||||
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
private request: Request | null;
|
||||||
|
|
||||||
|
// Signals for state
|
||||||
|
private _language = signal<Language>('EN');
|
||||||
|
private _darkMode = signal<boolean>(false);
|
||||||
|
private _romanisation = signal<boolean>(true);
|
||||||
|
private _userName = signal<string>('Guest');
|
||||||
|
private _reduceAnimation = signal<boolean>(false);
|
||||||
|
|
||||||
|
// Public computed or read-only signals
|
||||||
|
language = computed(() => this._language());
|
||||||
|
darkMode = computed(() => this._darkMode());
|
||||||
|
romanisation = computed(() => this._romanisation());
|
||||||
|
userName = computed(() => this._userName());
|
||||||
|
reduceAnimation = computed(() => this._reduceAnimation());
|
||||||
|
isGuest = computed(() => true);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.request = inject(REQUEST);
|
||||||
|
this.initializeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeSettings() {
|
||||||
|
let parsedCookie: Record<string, string>;
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
parsedCookie = this.parseCookies(document.cookie);
|
||||||
|
} else if (isPlatformServer(this.platformId)) {
|
||||||
|
const cookieHeader = this.request?.headers.get('Cookie');
|
||||||
|
|
||||||
|
if (!cookieHeader) return;
|
||||||
|
parsedCookie = this.parseCookies(cookieHeader);
|
||||||
|
} else return;
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
lang: parsedCookie['lang'],
|
||||||
|
darkMode: parsedCookie['darkMode'],
|
||||||
|
romanisation: parsedCookie['romanisation'],
|
||||||
|
userName: parsedCookie['userName'],
|
||||||
|
reduceAnimation: parsedCookie['reduceAnimation'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const windowDefined = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
if (!settings.lang) {
|
||||||
|
this.setLanguage('EN');
|
||||||
|
} else this._language.set(settings.lang as Language);
|
||||||
|
|
||||||
|
if (settings.darkMode === undefined) {
|
||||||
|
const isDarkMode = windowDefined ?
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches : false;
|
||||||
|
this.setDarkMode(isDarkMode);
|
||||||
|
} else this._darkMode.set(settings.darkMode === 'true');
|
||||||
|
|
||||||
|
if (settings.romanisation === undefined) {
|
||||||
|
this.setRomanisation(true);
|
||||||
|
} else this._romanisation.set(settings.romanisation === 'true');
|
||||||
|
|
||||||
|
if (!settings.userName) {
|
||||||
|
this.setUserName('Guest');
|
||||||
|
} else this._userName.set(settings.userName);
|
||||||
|
|
||||||
|
if (settings.reduceAnimation === undefined) {
|
||||||
|
const isReduceAnimation = windowDefined ?
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches : false;
|
||||||
|
this.setReduceAnimation(isReduceAnimation);
|
||||||
|
} else this._reduceAnimation.set(settings.reduceAnimation === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCookies(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
setReduceAnimation(value: boolean) {
|
||||||
|
this._reduceAnimation.set(value);
|
||||||
|
this.setCookie('reduceAnimation', value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCookie(name: string, value: string) {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
document.cookie = `${name}=${value}; path=/; max-age=31536000`; // 1 year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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]
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자열 내의 플레이스홀더(예: {{name}})를 실제 값으로 치환합니다.
|
||||||
|
*/
|
||||||
|
interpolate(text: string, params: Record<string, string | number>): string {
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(acc, [key, value]) => acc.replace(new RegExp(`{{${key}}}`, 'g'), String(value)),
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<div class="menu-left">
|
|
||||||
<div class="brand">
|
|
||||||
<img src="/logo_inverted.svg" alt="logo"/>
|
|
||||||
<span>ADOFAI Editor Forum</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span>Levels</span>
|
|
||||||
<span>Community</span>
|
|
||||||
<span>Leaderboard</span>
|
|
||||||
<span>About</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: start;
|
|
||||||
gap: 30px;
|
|
||||||
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 100%;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-bottom: oklch(0 0 0 / 20%) 2px solid;
|
|
||||||
background-color: oklch(1 0 0 / 60%);
|
|
||||||
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
|
|
||||||
contain: layout paint;
|
|
||||||
isolation: isolate;
|
|
||||||
z-index: 100;
|
|
||||||
|
|
||||||
.menu-left {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: start;
|
|
||||||
gap: 48px;
|
|
||||||
padding: 12px;
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: start;
|
|
||||||
gap: 0;
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: inline-block;
|
|
||||||
height: 20px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-weight: 900;
|
|
||||||
/*text-box-trim: trim-both;
|
|
||||||
text-box-edge: cap alphabetic;*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 40px;
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'header-menu',
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './header-menu.html',
|
|
||||||
styleUrl: './header-menu.scss',
|
|
||||||
})
|
|
||||||
export class HeaderMenu {}
|
|
||||||
+1
-2
@@ -2,10 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>AefComponentTest</title>
|
<title>AEF Component Test</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,300,0..1,-50..200" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,300,0..1,-50..200" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { config } from './app/app.config.server';
|
||||||
|
|
||||||
|
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
|
||||||
|
|
||||||
|
export default bootstrap;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
AngularNodeAppEngine,
|
||||||
|
createNodeRequestHandler,
|
||||||
|
isMainModule,
|
||||||
|
writeResponseToNodeResponse,
|
||||||
|
} from '@angular/ssr/node';
|
||||||
|
import express from 'express';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const browserDistFolder = join(import.meta.dirname, '../browser');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const angularApp = new AngularNodeAppEngine();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example Express Rest API endpoints can be defined here.
|
||||||
|
* Uncomment and define endpoints as necessary.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```ts
|
||||||
|
* app.get('/api/{*splat}', (req, res) => {
|
||||||
|
* // Handle API request
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static files from /browser
|
||||||
|
*/
|
||||||
|
app.use(
|
||||||
|
express.static(browserDistFolder, {
|
||||||
|
maxAge: '1y',
|
||||||
|
index: false,
|
||||||
|
redirect: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle all other requests by rendering the Angular application.
|
||||||
|
*/
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
angularApp
|
||||||
|
.handle(req)
|
||||||
|
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
|
||||||
|
.catch(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server if this module is the main entry point, or it is ran via PM2.
|
||||||
|
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
|
||||||
|
*/
|
||||||
|
if (isMainModule(import.meta.url) || process.env['pm_id']) {
|
||||||
|
const port = parseInt(process.env['PORT'] || '4000');
|
||||||
|
const host = '0.0.0.0';
|
||||||
|
|
||||||
|
app.listen(port, host, (error) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(`Node Express server listening on http://${host}:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
|
||||||
|
*/
|
||||||
|
export const reqHandler = createNodeRequestHandler(app);
|
||||||
+119
-1
@@ -1,12 +1,130 @@
|
|||||||
|
@use "sass:list";
|
||||||
|
@use "styles/library" as library;
|
||||||
|
|
||||||
@import url('https://cdn.jsdelivr.net/npm/nanumsquareneo@0.0.5/nanumsquareneo.css');
|
@import url('https://cdn.jsdelivr.net/npm/nanumsquareneo@0.0.5/nanumsquareneo.css');
|
||||||
@import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css');
|
@import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css');
|
||||||
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css");
|
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--motion-duration-multiplier: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.reduce-motion {
|
||||||
|
--motion-duration-multiplier: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--if-dark: initial;
|
||||||
|
|
||||||
|
&.dark-theme {
|
||||||
|
--if-dark: ;
|
||||||
|
color-scheme: dark;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
& > body {
|
||||||
|
background-color: oklch(0.1776 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.feature-block {
|
||||||
|
background-color: oklch(0 0 0 / 60%);
|
||||||
|
outline: 2px solid oklch(1 0 0 / 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif;
|
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
overscroll-behavior-x: none;
|
overscroll-behavior-x: none;
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-24 {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-40 {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: oklch(0.6830 0 0); // #999999
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0; margin-bottom: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-outlet {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#global-popup-root > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.feature-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
padding: 20px;
|
||||||
|
outline: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
background-color: oklch(1 0 0 / 60%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
--selection-outline-color: light-dark(#{list.nth(library.$brand_palette, 5)}, #{list.nth(library.$brand_palette, 6)});
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
+29
-18
@@ -1,21 +1,32 @@
|
|||||||
@forward 'functions';
|
@forward 'functions';
|
||||||
|
|
||||||
.icon-24 {
|
$brand_palette: [
|
||||||
font-variation-settings:
|
oklch(0.9738 0.0151 54.93), // #FFF4ED
|
||||||
'FILL' 0,
|
oklch(0.9386 0.0366 55.36), // #FFE5D4
|
||||||
'wght' 300,
|
oklch(0.8738 0.0761 51.59), // #FFC8A8
|
||||||
'GRAD' 0,
|
oklch(0.7938 0.1292 47.77), // #FFA170
|
||||||
'opsz' 24;
|
oklch(0.7086 0.1896 39.86), // #FF6E37
|
||||||
|
oklch(0.6662 0.2237 35.33), // #FF4B14
|
||||||
|
oklch(0.6159 0.2311 31.93), // #F02D06
|
||||||
|
oklch(0.5313 0.2041 30.75), // #C71D07
|
||||||
|
oklch(0.4509 0.1688 29.86), // #9E190E
|
||||||
|
oklch(0.3897 0.1391 29.75), // #7F180F
|
||||||
|
oklch(0.2547 0.0913 29.02), // #450805
|
||||||
|
oklch(0.1752 0.0717 29.02), // #280000
|
||||||
|
];
|
||||||
|
|
||||||
line-height: 24px;
|
/*
|
||||||
}
|
$brand_palette: [
|
||||||
|
oklch(0.9999 0.2237 35.33),
|
||||||
.icon-40 {
|
oklch(0.9090 0.2237 35.33),
|
||||||
font-variation-settings:
|
oklch(0.8181 0.2237 35.33),
|
||||||
'FILL' 0,
|
oklch(0.7272 0.2237 35.33),
|
||||||
'wght' 300,
|
oklch(0.6363 0.2237 35.33),
|
||||||
'GRAD' 0,
|
oklch(0.5454 0.2237 35.33),
|
||||||
'opsz' 40;
|
oklch(0.4545 0.2237 35.33),
|
||||||
|
oklch(0.3636 0.2237 35.33),
|
||||||
line-height: 40px;
|
oklch(0.2727 0.2237 35.33),
|
||||||
}
|
oklch(0.1818 0.2237 35.33),
|
||||||
|
oklch(0.0909 0.2237 35.33),
|
||||||
|
];
|
||||||
|
*/
|
||||||
+3
-7
@@ -4,12 +4,8 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src/**/*.ts"],
|
||||||
"src/**/*.ts"
|
"exclude": ["src/**/*.spec.ts"]
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"src/**/*.spec.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user