Compare commits
97
Commits
c65ec39dcd
..
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 | ||
|
|
688fea9033 |
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.
|
||||
|
||||
# Gemini CLI
|
||||
/.gemini
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
@@ -42,3 +45,5 @@ __screenshots__/
|
||||
# System files
|
||||
.DS_Store
|
||||
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.
|
||||
|
||||
## 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.
|
||||
For Testing Designed Components for ADOFAI Editor Forum Web
|
||||
+18
-1
@@ -32,7 +32,24 @@
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"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": {
|
||||
|
||||
@@ -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",
|
||||
"build": "ng build",
|
||||
"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,
|
||||
"packageManager": "npm@11.11.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/common": "^21.2.4",
|
||||
"@angular/compiler": "^21.2.4",
|
||||
"@angular/core": "^21.2.4",
|
||||
"@angular/forms": "^21.2.4",
|
||||
"@angular/platform-browser": "^21.2.4",
|
||||
"@angular/platform-server": "^21.2.4",
|
||||
"@angular/router": "^21.2.4",
|
||||
"@angular/ssr": "^21.2.2",
|
||||
"express": "^5.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.1",
|
||||
"@angular/cli": "^21.2.1",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@angular/build": "^21.2.2",
|
||||
"@angular/cli": "^21.2.2",
|
||||
"@angular/compiler-cli": "^21.2.4",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"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 { routes } from './app.routes';
|
||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
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>
|
||||
|
||||
<div class="container">
|
||||
<h1>AEF Component Test</h1>
|
||||
<main #content>
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<section>
|
||||
<h2>Switch Component</h2>
|
||||
<switch [(isActive)]="isToggled" (toggle)="handleToggle($event)"></switch>
|
||||
<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 id="global-popup-root" class="popup-outlet">
|
||||
@for (popup of popupService.activePopups(); track popup.component) {
|
||||
<ng-container *ngComponentOutlet="popup.component; inputs: popup.inputs" />
|
||||
}
|
||||
</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 { 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 {
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
:host {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 40px;
|
||||
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;
|
||||
main:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
+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 { Switch } from './components/switch/switch';
|
||||
import { Dropdown, DropdownOption } from './components/dropdown/dropdown';
|
||||
import { HeaderMenu } from './ui/header-menu/header-menu';
|
||||
import { HeaderMenu } from './features/header-menu/header-menu';
|
||||
import { PopupService } from './services/popup.service';
|
||||
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({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, Switch, Dropdown, HeaderMenu],
|
||||
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
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[] = [
|
||||
{ label: 'English', value: "EN" },
|
||||
{ label: '한국어', value: "KR" },
|
||||
{ label: '日本語', value: "JP" },
|
||||
];
|
||||
private contentElement = viewChild<ElementRef<HTMLElement>>("content");
|
||||
|
||||
selectedDropdownValue = signal<any>(null);
|
||||
selectedSize = signal<string | null>(null);
|
||||
private document = inject(DOCUMENT);
|
||||
private rootHtmlElement!: HTMLElement;
|
||||
|
||||
handleToggle(state: boolean) {
|
||||
console.log('Toggle state:', state);
|
||||
constructor() {
|
||||
if (isPlatformServer(this.platformID)) {
|
||||
this.rootHtmlElement = this.document.documentElement;
|
||||
} else if (isPlatformBrowser(this.platformID)) {
|
||||
this.rootHtmlElement = document.documentElement;
|
||||
}
|
||||
|
||||
handleDropdownChange(value: any) {
|
||||
console.log('Dropdown value:', value);
|
||||
effect(() => {
|
||||
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">
|
||||
@if (icon()) {
|
||||
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
|
||||
@if (icon()?.trim()?.length ?? 0 > 0) {
|
||||
<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>
|
||||
|
||||
@if (isOpen()) {
|
||||
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'">
|
||||
@for (option of normalizedOptions(); track option.value; let i = $index) {
|
||||
<div class="dropdown-container" tabindex="-1">
|
||||
<ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
|
||||
@for (option of filteredOptions(); track option.value; let i = $index) {
|
||||
<li
|
||||
#optionItem
|
||||
class="dropdown-item"
|
||||
role="option"
|
||||
[id]="dropdownId + '-option-' + i"
|
||||
[id]="dropdownID + '-option-' + i"
|
||||
[class.selected]="option.value === value()"
|
||||
[class.focused]="i === focusedIndex()"
|
||||
[attr.aria-selected]="option.value === value()"
|
||||
@@ -21,4 +37,5 @@
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -2,40 +2,54 @@
|
||||
@use "sass:color";
|
||||
@use "styles/library" as library;
|
||||
|
||||
$dropdown_bg: oklch(1 0 0); // FFFFFF
|
||||
$dropdown_border_color: oklch(0 0 0 / 20%);
|
||||
$dropdown_hover: oklch(0.9702 0 0); // F5F5F5
|
||||
$dropdown_selected: oklch(0.9738 0.0151 54.93); // FFF4ED
|
||||
$dropdown_accent: oklch(0.7086 0.1896 39.86); // #FF6E37
|
||||
$border_color: oklch(0 0 0 / 20%);
|
||||
$border_dark_color: oklch(1 0 0 / 20%);
|
||||
$hover_bg: oklch(0.9702 0 0); // #F5F5F5
|
||||
$hover_dark_bg: oklch(0.25 0 0);
|
||||
|
||||
$dropdown_height: 40px;
|
||||
$dropdown_border_radius: 8px;
|
||||
$height: 40px;
|
||||
$border_radius: 8px;
|
||||
|
||||
$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
$dropdown_animation_duration: 0.2s;
|
||||
$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;
|
||||
}
|
||||
|
||||
@property --selected-state {
|
||||
syntax: '<number>';
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||
padding: 0; margin: 0;
|
||||
|
||||
background-color: $dropdown_bg;
|
||||
border-radius: $dropdown_border_radius;
|
||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
||||
outline: none;
|
||||
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
||||
border-radius: $border_radius;
|
||||
box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 1px $dropdown_border_color inset, 0 0 0 2px white, 0 0 0 4px $dropdown_accent;
|
||||
max-width: 100%;
|
||||
|
||||
&:has(.dropdown-input.focus-mode:focus-visible) {
|
||||
outline: 2px solid var(--selection-outline-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
height: $dropdown_height;
|
||||
padding: 0 10px;
|
||||
height: $height;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
|
||||
@@ -46,19 +60,49 @@ $dropdown_animation_duration: 0.2s;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 18px;
|
||||
line-height: 1.2; // descender(g, y 등)가 잘리지 않도록 행간 확보
|
||||
height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 24px; height: 24px;
|
||||
flex-shrink: 0;
|
||||
transition: transform $dropdown_animation_duration $dropdown_animation_timing;
|
||||
transition: transform var(--animation-duration) $animation_timing;
|
||||
user-select: none;
|
||||
|
||||
&.open {
|
||||
@@ -67,36 +111,69 @@ $dropdown_animation_duration: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-list {
|
||||
.dropdown-container {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
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;
|
||||
list-style: none;
|
||||
background-color: $dropdown_bg;
|
||||
border-radius: $dropdown_border_radius;
|
||||
z-index: 1000;
|
||||
max-height: 240px;
|
||||
width: 100%;
|
||||
max-height: inherit;
|
||||
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 {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
border-radius: $dropdown_border_radius;
|
||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
||||
border-radius: $border_radius;
|
||||
box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
|
||||
pointer-events: none;
|
||||
z-index: 1001; // 테두리를 최상단으로!
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
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;
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
line-height: 1.2; // descender 보호를 위해 1에서 1.2로 수정
|
||||
transition: background-color $dropdown_animation_duration $dropdown_animation_timing;
|
||||
align-content: center;
|
||||
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 {
|
||||
background-color: $dropdown_hover;
|
||||
--hover-state: 1;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: $dropdown_selected;
|
||||
color: $dropdown_accent;
|
||||
--selected-state: 1;
|
||||
color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import { Component, input, model, output, signal, HostListener, ElementRef, computed
|
||||
} from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
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 {
|
||||
label: string;
|
||||
@@ -14,40 +30,111 @@ export interface DropdownOption {
|
||||
templateUrl: './dropdown.html',
|
||||
styleUrl: './dropdown.scss',
|
||||
host: {
|
||||
'role': 'combobox',
|
||||
role: 'combobox',
|
||||
'aria-haspopup': 'listbox',
|
||||
'[attr.aria-expanded]': 'isOpen()',
|
||||
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null",
|
||||
'[attr.aria-activedescendant]': "isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
|
||||
'[attr.aria-controls]': "isOpen() ? dropdownID + '-listbox' : null",
|
||||
'[attr.aria-activedescendant]': 'ariaActiveDescendant()',
|
||||
'[attr.aria-label]': 'ariaLabel()',
|
||||
'[class.open]': 'isOpen()',
|
||||
'tabindex': '0',
|
||||
tabindex: '0',
|
||||
'(click)': 'toggle()',
|
||||
'(focusin)': 'onFocusIn($event)',
|
||||
'(mousedown)': 'onMouseDown($event)',
|
||||
'(keydown)': 'onKeyDown($event)',
|
||||
'(keydown.enter)': 'onEnter($event)',
|
||||
'(keydown.space)': 'onSpaceDown($event)',
|
||||
'(keydown.escape)': 'close()',
|
||||
'(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>>([]);
|
||||
placeholder = input<string>('Select an option');
|
||||
placeholder = input<string>('');
|
||||
ariaLabel = input<string | undefined>(undefined);
|
||||
icon = input<string>();
|
||||
defaultIndex = input<number>();
|
||||
value = model<any>(null);
|
||||
selectionChange = output<any>();
|
||||
|
||||
isOpen = signal(false);
|
||||
inputFocusMode = signal(false);
|
||||
focusedIndex = signal(-1);
|
||||
searchQuery = signal('');
|
||||
recreateKey = signal(0);
|
||||
|
||||
private static nextId = 0;
|
||||
dropdownId = `dropdown-${Dropdown.nextId++}`;
|
||||
ariaActiveDescendant = computed(() => {
|
||||
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(() => {
|
||||
return this.options().map(opt => {
|
||||
return this.options().map((opt) => {
|
||||
if (typeof opt === 'string') {
|
||||
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() {
|
||||
this.isOpen.update(v => !v);
|
||||
if (this.isLatestMouseDownInsideInput) {
|
||||
this.isLatestMouseDownInsideInput = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
|
||||
const isDropdownOpen = this.isOpen();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -73,63 +223,150 @@ export class Dropdown {
|
||||
this.close();
|
||||
}
|
||||
|
||||
onEnter(event: Event) {
|
||||
onConfirm(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.isOpen()) {
|
||||
const currentIndex = this.focusedIndex();
|
||||
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
||||
this.selectOption(this.normalizedOptions()[currentIndex]);
|
||||
const options = this.filteredOptions();
|
||||
if (currentIndex >= 0 && currentIndex < options.length) {
|
||||
this.selectOption(options[currentIndex]);
|
||||
} else if (options.length === 0) {
|
||||
this.close();
|
||||
}
|
||||
} else {
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
onEnter(event: Event) {
|
||||
this.onConfirm(event);
|
||||
}
|
||||
|
||||
onSpaceDown(event: Event) {
|
||||
event.preventDefault();
|
||||
if (this.isOpen()) {
|
||||
const currentIndex = this.focusedIndex();
|
||||
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
||||
this.selectOption(this.normalizedOptions()[currentIndex]);
|
||||
}
|
||||
} else {
|
||||
this.toggle();
|
||||
}
|
||||
if (this.isOpen() && this.inputFocusMode()) return;
|
||||
this.onConfirm(event);
|
||||
}
|
||||
|
||||
onArrowDown(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.toggle();
|
||||
return;
|
||||
}
|
||||
this.focusedIndex.update(i => {
|
||||
|
||||
this.focusedIndex.update((i) => {
|
||||
const options = this.filteredOptions();
|
||||
if (options.length === 0) return -1;
|
||||
|
||||
if (i === -1) return 0;
|
||||
return (i + 1) % this.normalizedOptions().length;
|
||||
return (i + 1) % options.length;
|
||||
});
|
||||
}
|
||||
|
||||
onArrowUp(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.toggle();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent) {
|
||||
if (!this.el.nativeElement.contains(event.target)) {
|
||||
this.close();
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
const textArrowKeys = ['ArrowLeft', 'ArrowRight'];
|
||||
|
||||
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(() => {
|
||||
const selected = this.normalizedOptions().find(o => o.value === this.value());
|
||||
const selected = this.selectedOption();
|
||||
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 "styles/library" as library;
|
||||
|
||||
$switch_on_bg: oklch(0.7086 0.1896 39.86); // #FF6E37
|
||||
$switch_off_bg: oklch(0.683 0 0); // #999999
|
||||
$off_bg: oklch(0.683 0 0); // #999999
|
||||
$off_dark_bg: oklch(0 0 0);
|
||||
|
||||
$switch_dimension: (57px, 30px);
|
||||
$switch_slider_dimension: (24px, 24px);
|
||||
$switch_padding: (3px, 3px);
|
||||
$switch_border_radius: 15px;
|
||||
$switch_border_color: oklch(0 0 0 / 20%);
|
||||
$dimension: (57px, 30px);
|
||||
$knob_dimension: (24px, 24px);
|
||||
$internal_padding: (3px, 3px);
|
||||
|
||||
$switch_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
$switch_animation_duration: 0.3s;
|
||||
$border_radius: 15px;
|
||||
$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 {
|
||||
display: inline-block;
|
||||
width: list.nth($switch_dimension, 1); height: list.nth($switch_dimension, 2);
|
||||
border-radius: $switch_border_radius;
|
||||
background-color: $switch_off_bg;
|
||||
width: list.nth($dimension, 1); height: list.nth($dimension, 2);
|
||||
border-radius: $border_radius;
|
||||
|
||||
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;
|
||||
position: relative;
|
||||
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 {
|
||||
.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 {
|
||||
background-color: $switch_on_bg;
|
||||
--animation-state: 1;
|
||||
|
||||
.switch-knob {
|
||||
transform: translate(
|
||||
list.nth($switch_dimension, 1) - list.nth($switch_slider_dimension, 1) - list.nth($switch_padding, 1) * 2,
|
||||
list.nth($switch_dimension, 2) - list.nth($switch_slider_dimension, 2) - list.nth($switch_padding, 2) * 2
|
||||
list.nth($dimension, 1) - list.nth($knob_dimension, 1) - list.nth($internal_padding, 1) * 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 {
|
||||
position: absolute;
|
||||
width: list.nth($switch_slider_dimension, 1); height: list.nth($switch_slider_dimension, 2);
|
||||
left: list.nth($switch_padding, 1); top: list.nth($switch_padding, 2);
|
||||
width: list.nth($knob_dimension, 1); height: list.nth($knob_dimension, 2);
|
||||
left: list.nth($internal_padding, 1); top: list.nth($internal_padding, 2);
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
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({
|
||||
selector: 'switch',
|
||||
@@ -10,7 +10,6 @@ import { Component, input, model, output } from '@angular/core';
|
||||
tabindex: '0',
|
||||
'[attr.aria-checked]': 'isActive()',
|
||||
'[class.active]': 'isActive()',
|
||||
'[class.reduce-motion]': 'reduceMotion()',
|
||||
'(click)': 'onClick()',
|
||||
'(keydown.enter)': 'onClick()',
|
||||
'(keydown.space)': 'onSpaceDown($event)',
|
||||
@@ -18,11 +17,6 @@ import { Component, input, model, output } from '@angular/core';
|
||||
})
|
||||
export class Switch {
|
||||
isActive = model<boolean>(false);
|
||||
reduceMotion = input<boolean>(
|
||||
typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false,
|
||||
);
|
||||
toggle = output<boolean>();
|
||||
|
||||
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,50 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 30px;
|
||||
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-bottom: oklch(0 0 0 / 20%) 2px solid;
|
||||
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.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;
|
||||
line-height: 40px;
|
||||
|
||||
/*text-box-trim: trim-both;
|
||||
text-box-edge: cap alphabetic;*/
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 18px;
|
||||
line-height: 40px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AefComponentTest</title>
|
||||
<title>AEF Component Test</title>
|
||||
<base href="/">
|
||||
<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" />
|
||||
</head>
|
||||
<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);
|
||||
+120
@@ -1,10 +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/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");
|
||||
|
||||
: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 {
|
||||
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
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';
|
||||
|
||||
.icon-24 {
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 300,
|
||||
'GRAD' 0,
|
||||
'opsz' 24;
|
||||
$brand_palette: [
|
||||
oklch(0.9738 0.0151 54.93), // #FFF4ED
|
||||
oklch(0.9386 0.0366 55.36), // #FFE5D4
|
||||
oklch(0.8738 0.0761 51.59), // #FFC8A8
|
||||
oklch(0.7938 0.1292 47.77), // #FFA170
|
||||
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;
|
||||
}
|
||||
|
||||
.icon-40 {
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 300,
|
||||
'GRAD' 0,
|
||||
'opsz' 40;
|
||||
|
||||
line-height: 40px;
|
||||
}
|
||||
/*
|
||||
$brand_palette: [
|
||||
oklch(0.9999 0.2237 35.33),
|
||||
oklch(0.9090 0.2237 35.33),
|
||||
oklch(0.8181 0.2237 35.33),
|
||||
oklch(0.7272 0.2237 35.33),
|
||||
oklch(0.6363 0.2237 35.33),
|
||||
oklch(0.5454 0.2237 35.33),
|
||||
oklch(0.4545 0.2237 35.33),
|
||||
oklch(0.3636 0.2237 35.33),
|
||||
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",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user