Compare commits
63
Commits
2d6e0995c7
..
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 |
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
Chrome >= 111
|
||||||
|
Firefox >= 113
|
||||||
|
Edge >= 111
|
||||||
|
not dead
|
||||||
@@ -1,59 +1,3 @@
|
|||||||
# AefComponentTest
|
# Component Test
|
||||||
|
|
||||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
|
For Testing Designed Components for ADOFAI Editor Forum Web
|
||||||
|
|
||||||
## Development server
|
|
||||||
|
|
||||||
To start a local development server, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
|
||||||
|
|
||||||
## Code scaffolding
|
|
||||||
|
|
||||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate component component-name
|
|
||||||
```
|
|
||||||
|
|
||||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To build the project run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
|
||||||
|
|
||||||
## Running unit tests
|
|
||||||
|
|
||||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running end-to-end tests
|
|
||||||
|
|
||||||
For end-to-end (e2e) testing, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
|
||||||
@@ -45,6 +45,11 @@
|
|||||||
},
|
},
|
||||||
"ssr": {
|
"ssr": {
|
||||||
"entry": "src/server.ts"
|
"entry": "src/server.ts"
|
||||||
|
},
|
||||||
|
"optimization": {
|
||||||
|
"scripts": true,
|
||||||
|
"styles": true,
|
||||||
|
"fonts": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트
|
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트
|
||||||
|
|
||||||
$IMAGE_NAME = "aef-component-test"
|
$IMAGE_NAME = "aef-component-test"
|
||||||
$TAG = "0.0.4"
|
$TAG = "0.0.9"
|
||||||
$OUTPUT_FILE = "aef-component-test.tar"
|
$OUTPUT_FILE = "aef-component-test.tar"
|
||||||
|
|
||||||
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
|
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
|
||||||
docker build -t "$($IMAGE_NAME):$TAG" .
|
docker build --no-cache -t "$($IMAGE_NAME):$TAG" .
|
||||||
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Host "--- 이미지 추출 시작: $OUTPUT_FILE ---" -ForegroundColor Cyan
|
Write-Host "--- 이미지 추출 시작: $OUTPUT_FILE ---" -ForegroundColor Cyan
|
||||||
|
|||||||
Generated
+179
-37
@@ -310,13 +310,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/schematics": {
|
"node_modules/@angular-devkit/schematics": {
|
||||||
"version": "21.2.2",
|
"version": "21.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.7.tgz",
|
||||||
"integrity": "sha512-CCeyQxGUq+oyGnHd7PfcYIVbj9pRnqjQq0rAojoAqs1BJdtInx9weLBCLy+AjM3NHePeZrnwm+wEVr8apED8kg==",
|
"integrity": "sha512-LYAjjUI1qM7pR/sd0yYt8OLA6ljOOXjcfzV40I5XQNmhAxq90YYS5xwMcixOmWX+z5zvCYGvPXvJGWjzio6SUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-devkit/core": "21.2.2",
|
"@angular-devkit/core": "21.2.7",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"magic-string": "0.30.21",
|
"magic-string": "0.30.21",
|
||||||
"ora": "9.3.0",
|
"ora": "9.3.0",
|
||||||
@@ -328,6 +328,47 @@
|
|||||||
"yarn": ">= 1.13.0"
|
"yarn": ">= 1.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": {
|
||||||
|
"version": "21.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
|
||||||
|
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "8.18.0",
|
||||||
|
"ajv-formats": "3.0.1",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"picomatch": "4.0.4",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"source-map": "0.7.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"chokidar": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"chokidar": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/picomatch": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular/build": {
|
"node_modules/@angular/build": {
|
||||||
"version": "21.2.2",
|
"version": "21.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.2.tgz",
|
||||||
@@ -429,19 +470,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/cli": {
|
"node_modules/@angular/cli": {
|
||||||
"version": "21.2.2",
|
"version": "21.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.7.tgz",
|
||||||
"integrity": "sha512-eZo8/qX+ZIpIWc0CN+cCX13Lbgi/031wAp8DRVhDDO6SMVtcr/ObOQ2S16+pQdOMXxiG3vby6IhzJuz9WACzMQ==",
|
"integrity": "sha512-N/wj8fFRB718efIFYpwnYfy+MecZREZXsUNMTVndFLH6T0jCheb9PVetR6jsyZp6h46USNPOmJYJ/9255lME+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-devkit/architect": "0.2102.2",
|
"@angular-devkit/architect": "0.2102.7",
|
||||||
"@angular-devkit/core": "21.2.2",
|
"@angular-devkit/core": "21.2.7",
|
||||||
"@angular-devkit/schematics": "21.2.2",
|
"@angular-devkit/schematics": "21.2.7",
|
||||||
"@inquirer/prompts": "7.10.1",
|
"@inquirer/prompts": "7.10.1",
|
||||||
"@listr2/prompt-adapter-inquirer": "3.0.5",
|
"@listr2/prompt-adapter-inquirer": "3.0.5",
|
||||||
"@modelcontextprotocol/sdk": "1.26.0",
|
"@modelcontextprotocol/sdk": "1.26.0",
|
||||||
"@schematics/angular": "21.2.2",
|
"@schematics/angular": "21.2.7",
|
||||||
"@yarnpkg/lockfile": "1.1.0",
|
"@yarnpkg/lockfile": "1.1.0",
|
||||||
"algoliasearch": "5.48.1",
|
"algoliasearch": "5.48.1",
|
||||||
"ini": "6.0.0",
|
"ini": "6.0.0",
|
||||||
@@ -463,6 +504,66 @@
|
|||||||
"yarn": ">= 1.13.0"
|
"yarn": ">= 1.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular/cli/node_modules/@angular-devkit/architect": {
|
||||||
|
"version": "0.2102.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.7.tgz",
|
||||||
|
"integrity": "sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular-devkit/core": "21.2.7",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"architect": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular/cli/node_modules/@angular-devkit/core": {
|
||||||
|
"version": "21.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
|
||||||
|
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "8.18.0",
|
||||||
|
"ajv-formats": "3.0.1",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"picomatch": "4.0.4",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"source-map": "0.7.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"chokidar": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"chokidar": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular/cli/node_modules/picomatch": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular/common": {
|
"node_modules/@angular/common": {
|
||||||
"version": "21.2.4",
|
"version": "21.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.4.tgz",
|
||||||
@@ -629,9 +730,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/ssr": {
|
"node_modules/@angular/ssr": {
|
||||||
"version": "21.2.2",
|
"version": "21.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.7.tgz",
|
||||||
"integrity": "sha512-ZEdE0G3+v0JCtSxpBynSlGImXi47HwWGqANtfHcOJUdf6GGKzSzoyliT1+7Lu2TR4TyCjtDq/RQH5bETr3zghA==",
|
"integrity": "sha512-NhrkeD32s3H/jU9yJLqDy2JBNNatFyzqNkwieJw0waEvBRNbxXlcg5+g6rilcg2nHlH5hyzMQUzs7ZwZH9wCqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -1660,9 +1761,9 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.11",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3880,14 +3981,14 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@schematics/angular": {
|
"node_modules/@schematics/angular": {
|
||||||
"version": "21.2.2",
|
"version": "21.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.7.tgz",
|
||||||
"integrity": "sha512-Ywa6HDtX7TRBQZTVMMnxX3Mk7yVnG8KtSFaXWrkx779+q8tqYdBwNwAqbNd4Zatr1GccKaz9xcptHJta5+DTxw==",
|
"integrity": "sha512-aqEj3RyBtmH+41HZvrbfrpCo0e+0NzwyQyNSC/wLDShVqoidBtPbEdHU1FZ4+ni41da7rI3F12gUuAHws27kMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-devkit/core": "21.2.2",
|
"@angular-devkit/core": "21.2.7",
|
||||||
"@angular-devkit/schematics": "21.2.2",
|
"@angular-devkit/schematics": "21.2.7",
|
||||||
"jsonc-parser": "3.3.1"
|
"jsonc-parser": "3.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3896,6 +3997,47 @@
|
|||||||
"yarn": ">= 1.13.0"
|
"yarn": ">= 1.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@schematics/angular/node_modules/@angular-devkit/core": {
|
||||||
|
"version": "21.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
|
||||||
|
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "8.18.0",
|
||||||
|
"ajv-formats": "3.0.1",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"picomatch": "4.0.4",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"source-map": "0.7.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"chokidar": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"chokidar": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@schematics/angular/node_modules/picomatch": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sigstore/bundle": {
|
"node_modules/@sigstore/bundle": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz",
|
||||||
@@ -4503,9 +4645,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5671,9 +5813,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.5",
|
"version": "4.12.14",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7125,9 +7267,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -7954,9 +8096,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/stdin-discarder": {
|
"node_modules/stdin-discarder": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
|
||||||
"integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==",
|
"integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8007,9 +8149,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.10",
|
"version": "7.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||||
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
|
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,12 @@
|
|||||||
|
<skip-links ngSkipHydration>
|
||||||
|
<button (click)="skipToContent()">{{ translation.common.skipToContent }}</button>
|
||||||
|
</skip-links>
|
||||||
|
|
||||||
<header-menu></header-menu>
|
<header-menu></header-menu>
|
||||||
<router-outlet />
|
|
||||||
|
<main #content>
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
<div id="global-popup-root" class="popup-outlet">
|
<div id="global-popup-root" class="popup-outlet">
|
||||||
@for (popup of popupService.activePopups(); track popup.component) {
|
@for (popup of popupService.activePopups(); track popup.component) {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Routes } from '@angular/router';
|
|||||||
import { MainPage } from './pages/main/main';
|
import { MainPage } from './pages/main/main';
|
||||||
import { KnownIssuesPage } from './pages/known-issues/known-issues';
|
import { KnownIssuesPage } from './pages/known-issues/known-issues';
|
||||||
import { ClockPage } from './pages/clock/clock';
|
import { ClockPage } from './pages/clock/clock';
|
||||||
|
import { LevelPage } from './pages/levels/levels';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: MainPage },
|
{ path: '', component: MainPage },
|
||||||
{ path: 'known-issues', component: KnownIssuesPage },
|
{ path: 'known-issues', component: KnownIssuesPage },
|
||||||
{ path: 'clock', component: ClockPage },
|
{ path: 'clock', component: ClockPage },
|
||||||
|
{ path: 'levels', component: LevelPage },
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
main:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
+45
-13
@@ -1,31 +1,63 @@
|
|||||||
import { Component, inject, effect, PLATFORM_ID } from '@angular/core';
|
import { Component, inject, effect, PLATFORM_ID, ElementRef, viewChild } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { HeaderMenu } from './features/header-menu/header-menu';
|
import { HeaderMenu } from './features/header-menu/header-menu';
|
||||||
import { PopupService } from './services/popup.service';
|
import { PopupService } from './services/popup.service';
|
||||||
import { SettingsService } from './services/settings.service';
|
import { SettingsService } from './services/settings.service';
|
||||||
import { NgComponentOutlet, isPlatformBrowser } from '@angular/common';
|
import { NgComponentOutlet, isPlatformBrowser, isPlatformServer, DOCUMENT } from '@angular/common';
|
||||||
|
import { SkipLinks } from './components/skip-links/skip-links';
|
||||||
|
import { TranslationService } from './services/translation.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet],
|
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected popupService = inject(PopupService);
|
protected popupService = inject(PopupService);
|
||||||
protected settingsService = inject(SettingsService);
|
protected settingsService = inject(SettingsService);
|
||||||
private platformId = inject(PLATFORM_ID);
|
protected translation = inject(TranslationService).translation;
|
||||||
|
private platformID = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
private contentElement = viewChild<ElementRef<HTMLElement>>("content");
|
||||||
|
|
||||||
|
private document = inject(DOCUMENT);
|
||||||
|
private rootHtmlElement!: HTMLElement;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
if (isPlatformServer(this.platformID)) {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
this.rootHtmlElement = this.document.documentElement;
|
||||||
const isDark = this.settingsService.darkMode();
|
} else if (isPlatformBrowser(this.platformID)) {
|
||||||
document.documentElement.classList.toggle('dark-theme', isDark);
|
this.rootHtmlElement = document.documentElement;
|
||||||
document.documentElement.classList.toggle('light-theme', !isDark);
|
|
||||||
|
|
||||||
const lang = this.settingsService.language();
|
|
||||||
document.documentElement.lang = lang.toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild } from '@angular/core';
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
NgZone,
|
||||||
|
OnDestroy,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { SettingsService } from '../../services/settings.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'brand-accent',
|
selector: 'brand-accent',
|
||||||
@@ -7,13 +19,27 @@ import { CommonModule } from '@angular/common';
|
|||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './brand-accent.html',
|
templateUrl: './brand-accent.html',
|
||||||
styleUrl: './brand-accent.scss',
|
styleUrl: './brand-accent.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class BrandAccent implements OnDestroy {
|
export class BrandAccent implements OnDestroy {
|
||||||
@ViewChild('canvasElement') canvasRef!: ElementRef<HTMLCanvasElement>;
|
canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('canvasElement');
|
||||||
private ctx!: CanvasRenderingContext2D;
|
private ctx!: CanvasRenderingContext2D;
|
||||||
private resizeObserver?: ResizeObserver;
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
|
drawGrid = input<boolean>(true);
|
||||||
|
|
||||||
|
private bgRendererScale = 1;
|
||||||
private canvasNativeSize: [number, number] = [0, 0];
|
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: {
|
private animation: {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -33,41 +59,166 @@ export class BrandAccent implements OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constructor(private ngZone: NgZone) {
|
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(() => {
|
afterNextRender(() => {
|
||||||
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
const nativeElement = this.canvasRef()?.nativeElement;
|
||||||
|
|
||||||
|
if (nativeElement) {
|
||||||
|
this.ctx = nativeElement.getContext('2d', { colorSpace: 'srgb' })!;
|
||||||
|
|
||||||
this.ngZone.runOutsideAngular(() => {
|
this.ngZone.runOutsideAngular(() => {
|
||||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
this.resizeObserver = new ResizeObserver((entries) => {
|
||||||
this.resizeObserver.observe(this.canvasRef.nativeElement);
|
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.resize(); // Safe Initialization
|
|
||||||
this.animation.startTime = performance.now();
|
this.animation.startTime = performance.now();
|
||||||
this.draw();
|
this.draw();
|
||||||
|
// this.animate(performance.now());
|
||||||
this.animate(performance.now());
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private resize() {
|
private resize(width: number, height: number) {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
const canvas = this.canvasRef()?.nativeElement;
|
||||||
|
if (canvas) {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
|
|
||||||
canvas.width = rect.width * dpr;
|
this.rendererScale = dpr;
|
||||||
canvas.height = rect.height * 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.ctx.scale(dpr, dpr);
|
||||||
this.updateAnimationInfo();
|
this.updateAnimationInfo();
|
||||||
|
this.animate(performance.now());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAnimationInfo() {
|
private updateAnimationInfo() {
|
||||||
this.canvasNativeSize = [this.canvasRef.nativeElement.clientWidth, this.canvasRef.nativeElement.clientHeight];
|
|
||||||
|
|
||||||
this.animation.gridDimension = [
|
this.animation.gridDimension = [
|
||||||
Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3,
|
Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3,
|
||||||
Math.trunc(this.canvasNativeSize[1] / 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) => {
|
private animate = (currentTime: number) => {
|
||||||
@@ -87,50 +238,75 @@ export class BrandAccent implements OnDestroy {
|
|||||||
|
|
||||||
private draw() {
|
private draw() {
|
||||||
this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
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.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
const linearGradient = this.ctx.createLinearGradient(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
this.ctx.imageSmoothingEnabled = false;
|
||||||
|
this.ctx.drawImage(
|
||||||
|
this.offscreenCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this.canvasNativeSize[0],
|
||||||
|
this.canvasNativeSize[1],
|
||||||
|
);
|
||||||
|
|
||||||
linearGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)');
|
// this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
||||||
linearGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
|
}
|
||||||
|
|
||||||
this.ctx.fillStyle = linearGradient;
|
if (!this.drawGrid()) return;
|
||||||
this.ctx.fillRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
|
||||||
|
|
||||||
|
if (!isDark) {
|
||||||
this.ctx.globalCompositeOperation = 'multiply';
|
this.ctx.globalCompositeOperation = 'multiply';
|
||||||
|
} else {
|
||||||
const gridGradient = this.ctx.createLinearGradient(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
this.ctx.globalCompositeOperation = 'screen';
|
||||||
gridGradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
}
|
||||||
gridGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
|
|
||||||
|
|
||||||
this.ctx.beginPath();
|
this.ctx.beginPath();
|
||||||
this.ctx.lineWidth = 2;
|
this.ctx.lineWidth = 2;
|
||||||
this.ctx.strokeStyle = gridGradient;
|
this.ctx.strokeStyle = this.gridGradient;
|
||||||
this.ctx.lineCap = 'round';
|
this.ctx.lineCap = 'round';
|
||||||
|
|
||||||
const lineRenderMargin = this.animation.gridSquareSize * 2;
|
const lineRenderMargin = this.animation.gridSquareSize * 2;
|
||||||
|
|
||||||
const xOffset = (this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) / 2;
|
const xOffset =
|
||||||
const yOffset = (this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) / 2;
|
(this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) /
|
||||||
const movingOffset = -(this.animation.gridSquareSize * this.animation.progress);
|
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++) {
|
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
|
||||||
this.ctx.moveTo(-lineRenderMargin, y * this.animation.gridSquareSize + yOffset + movingOffset);
|
this.ctx.moveTo(
|
||||||
this.ctx.lineTo(this.canvasNativeSize[0] + lineRenderMargin, y * this.animation.gridSquareSize + yOffset + movingOffset);
|
-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++) {
|
for (let x = 0; x < this.animation.gridDimension[0]; x++) {
|
||||||
this.ctx.moveTo(x * this.animation.gridSquareSize + xOffset + movingOffset, -lineRenderMargin);
|
this.ctx.moveTo(
|
||||||
this.ctx.lineTo(x * this.animation.gridSquareSize + xOffset + movingOffset, this.canvasNativeSize[1] + lineRenderMargin);
|
x * this.animation.gridSquareSize + xOffset + movingOffset,
|
||||||
|
-lineRenderMargin,
|
||||||
|
);
|
||||||
|
this.ctx.lineTo(
|
||||||
|
x * this.animation.gridSquareSize + xOffset + movingOffset,
|
||||||
|
this.canvasNativeSize[1] + lineRenderMargin,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ctx.stroke()
|
this.ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
if (this.animation.id) {
|
if (this.animation.id) cancelAnimationFrame(this.animation.id);
|
||||||
cancelAnimationFrame(this.animation.id);
|
|
||||||
}
|
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
|
this.intersectionObserver?.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
.clock-date {
|
.clock-date {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: oklch(0.3211 0 0); // #333
|
color: light-dark(oklch(0.3211 0 0), oklch(0.6789 0 0)); // #333
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-time {
|
.clock-time {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: oklch(0 0 0); // #000
|
color: light-dark(oklch(0 0 0), oklch(1 0 0)); // #000
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ export class Clock implements OnInit, OnDestroy {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
this.currentTime.set(new Date());
|
this.currentTime.set(new Date());
|
||||||
}, 1000);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
|
<ng-template #inputTemplate>
|
||||||
|
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
|
||||||
|
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
|
||||||
|
[attr.placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" tabindex="-1" spellcheck="false"
|
||||||
|
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"
|
||||||
|
(focus)="onInputFocusIn()" (focusout)="onInputFocusOut()"></div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<div class="dropdown-box">
|
<div class="dropdown-box">
|
||||||
@if (icon()) {
|
@if (icon()?.trim()?.length ?? 0 > 0) {
|
||||||
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
|
<span class="material-symbols-outlined icon-24 prefix-icon" aria-hidden="true">{{ icon() }}</span>
|
||||||
}
|
}
|
||||||
<span class="placeholder">{{ selectedLabel() }}</span>
|
|
||||||
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span>
|
@if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
||||||
|
@else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
|
||||||
|
|
||||||
|
@if (!isOpen()) {
|
||||||
|
<span class="placeholder" [class.value-empty]="selectedOption() === undefined">{{ selectedLabel() }}</span>
|
||||||
|
}
|
||||||
|
<span class="material-symbols-outlined icon-24 chevron" aria-hidden="true" [class.open]="isOpen()">keyboard_arrow_down</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isOpen()) {
|
@if (isOpen()) {
|
||||||
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'">
|
<div class="dropdown-container" tabindex="-1">
|
||||||
@for (option of normalizedOptions(); track option.value; let i = $index) {
|
<ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
|
||||||
|
@for (option of filteredOptions(); track option.value; let i = $index) {
|
||||||
<li
|
<li
|
||||||
|
#optionItem
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
role="option"
|
role="option"
|
||||||
[id]="dropdownId + '-option-' + i"
|
[id]="dropdownID + '-option-' + i"
|
||||||
[class.selected]="option.value === value()"
|
[class.selected]="option.value === value()"
|
||||||
[class.focused]="i === focusedIndex()"
|
[class.focused]="i === focusedIndex()"
|
||||||
[attr.aria-selected]="option.value === value()"
|
[attr.aria-selected]="option.value === value()"
|
||||||
@@ -21,4 +37,5 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@@ -2,32 +2,46 @@
|
|||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
@use "styles/library" as library;
|
@use "styles/library" as library;
|
||||||
|
|
||||||
$dropdown_bg: oklch(1 0 0); // #FFFFFF
|
$border_color: oklch(0 0 0 / 20%);
|
||||||
$dropdown_border_color: oklch(0 0 0 / 20%);
|
$border_dark_color: oklch(1 0 0 / 20%);
|
||||||
$dropdown_hover: oklch(0.9702 0 0); // #F5F5F5
|
$hover_bg: oklch(0.9702 0 0); // #F5F5F5
|
||||||
|
$hover_dark_bg: oklch(0.25 0 0);
|
||||||
|
|
||||||
$dropdown_height: 40px;
|
$height: 40px;
|
||||||
$dropdown_border_radius: 8px;
|
$border_radius: 8px;
|
||||||
|
|
||||||
$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
$dropdown_animation_duration: 0.2s;
|
$default_animation_duration: 0.2s;
|
||||||
|
|
||||||
|
@property --hover-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --selected-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
padding: 0; margin: 0;
|
padding: 0; margin: 0;
|
||||||
|
|
||||||
background-color: $dropdown_bg;
|
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
||||||
border-radius: $dropdown_border_radius;
|
border-radius: $border_radius;
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
--dropdown-duration: #{$dropdown_animation_duration};
|
|
||||||
|
|
||||||
&.reduce-motion {
|
&:has(.dropdown-input.focus-mode:focus-visible) {
|
||||||
--dropdown-duration: 0s;
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +49,7 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
height: $dropdown_height;
|
height: $height;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
@@ -46,12 +60,40 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
&.value-empty { opacity: 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-input {
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
&:focus-visible { outline: none; }
|
||||||
|
&:empty:before {
|
||||||
|
content: attr(placeholder);
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.focus-mode) {
|
||||||
|
caret-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder, .dropdown-input {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
align-content: center;
|
align-content: center;
|
||||||
@@ -60,7 +102,7 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
.chevron {
|
.chevron {
|
||||||
width: 24px; height: 24px;
|
width: 24px; height: 24px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: transform var(--dropdown-duration) $dropdown_animation_timing;
|
transition: transform var(--animation-duration) $animation_timing;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
@@ -69,35 +111,69 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-list {
|
.dropdown-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
||||||
background-color: $dropdown_bg;
|
border-radius: $border_radius;
|
||||||
border-radius: $dropdown_border_radius;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
max-height: 240px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: hidden;
|
||||||
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
|
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
|
||||||
animation: slideIn var(--dropdown-duration) $dropdown_animation_timing;
|
animation: slideIn var(--animation-duration) $animation_timing;
|
||||||
|
|
||||||
|
.dropdown-list {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
width: 100%;
|
||||||
|
max-height: inherit;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
scrollbar-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%)) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
|
||||||
|
background-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
border-radius: $dropdown_border_radius;
|
border-radius: $border_radius;
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from { opacity: 0; transform: translateY(-8px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
transform: translateY(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,15 +184,32 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
transition: background-color var(--dropdown-duration) $dropdown_animation_timing;
|
transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing, --selected-state calc(var(--animation-duration) / 3) $animation_timing;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklch,
|
||||||
|
color-mix(
|
||||||
|
in oklch,
|
||||||
|
transparent,
|
||||||
|
light-dark($hover_bg, $hover_dark_bg)
|
||||||
|
calc(var(--hover-state) * 100%)
|
||||||
|
),
|
||||||
|
light-dark(list.nth(library.$brand_palette, 1), list.nth(library.$brand_palette, 12))
|
||||||
|
calc(var(--selected-state) * 100%)
|
||||||
|
);
|
||||||
|
|
||||||
|
&.focused {
|
||||||
|
--animation-duration: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover, &.focused {
|
&:hover, &.focused {
|
||||||
background-color: $dropdown_hover;
|
--hover-state: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
background-color: list.nth(library.$brand_palette, 1);
|
--selected-state: 1;
|
||||||
color: list.nth(library.$brand_palette, 5);
|
color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import { Component, input, model, output, signal, HostListener, ElementRef, computed, OnInit
|
import {
|
||||||
} from '@angular/core'
|
Component,
|
||||||
import { CommonModule } from '@angular/common'
|
computed,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
model,
|
||||||
|
OnInit,
|
||||||
|
output,
|
||||||
|
PLATFORM_ID,
|
||||||
|
signal,
|
||||||
|
untracked,
|
||||||
|
viewChild,
|
||||||
|
viewChildren
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
export interface DropdownOption {
|
export interface DropdownOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -17,14 +33,15 @@ export interface DropdownOption {
|
|||||||
role: 'combobox',
|
role: 'combobox',
|
||||||
'aria-haspopup': 'listbox',
|
'aria-haspopup': 'listbox',
|
||||||
'[attr.aria-expanded]': 'isOpen()',
|
'[attr.aria-expanded]': 'isOpen()',
|
||||||
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null",
|
'[attr.aria-controls]': "isOpen() ? dropdownID + '-listbox' : null",
|
||||||
'[attr.aria-activedescendant]':
|
'[attr.aria-activedescendant]': 'ariaActiveDescendant()',
|
||||||
"isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
|
|
||||||
'[attr.aria-label]': 'ariaLabel()',
|
'[attr.aria-label]': 'ariaLabel()',
|
||||||
'[class.open]': 'isOpen()',
|
'[class.open]': 'isOpen()',
|
||||||
'[class.reduce-motion]': 'reduceMotion()',
|
|
||||||
tabindex: '0',
|
tabindex: '0',
|
||||||
'(click)': 'toggle()',
|
'(click)': 'toggle()',
|
||||||
|
'(focusin)': 'onFocusIn($event)',
|
||||||
|
'(mousedown)': 'onMouseDown($event)',
|
||||||
|
'(keydown)': 'onKeyDown($event)',
|
||||||
'(keydown.enter)': 'onEnter($event)',
|
'(keydown.enter)': 'onEnter($event)',
|
||||||
'(keydown.space)': 'onSpaceDown($event)',
|
'(keydown.space)': 'onSpaceDown($event)',
|
||||||
'(keydown.escape)': 'close()',
|
'(keydown.escape)': 'close()',
|
||||||
@@ -33,8 +50,13 @@ export interface DropdownOption {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Dropdown implements OnInit {
|
export class Dropdown implements OnInit {
|
||||||
|
translation = inject(TranslationService).translation;
|
||||||
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
searchInput = viewChild<ElementRef<HTMLElement>>('searchInput');
|
||||||
|
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
|
||||||
|
|
||||||
options = input<Array<DropdownOption | string>>([]);
|
options = input<Array<DropdownOption | string>>([]);
|
||||||
placeholder = input<string>('Select an option');
|
placeholder = input<string>('');
|
||||||
ariaLabel = input<string | undefined>(undefined);
|
ariaLabel = input<string | undefined>(undefined);
|
||||||
icon = input<string>();
|
icon = input<string>();
|
||||||
defaultIndex = input<number>();
|
defaultIndex = input<number>();
|
||||||
@@ -42,18 +64,53 @@ export class Dropdown implements OnInit {
|
|||||||
selectionChange = output<any>();
|
selectionChange = output<any>();
|
||||||
|
|
||||||
isOpen = signal(false);
|
isOpen = signal(false);
|
||||||
|
inputFocusMode = signal(false);
|
||||||
focusedIndex = signal(-1);
|
focusedIndex = signal(-1);
|
||||||
|
searchQuery = signal('');
|
||||||
|
recreateKey = signal(0);
|
||||||
|
|
||||||
private static nextId = 0;
|
ariaActiveDescendant = computed(() => {
|
||||||
dropdownId = `dropdown-${Dropdown.nextId++}`;
|
return this.isOpen() && this.focusedIndex() >= 0
|
||||||
|
? this.dropdownID + '-option-' + this.focusedIndex()
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
reduceMotion = input<boolean>(
|
private static nextID = 0;
|
||||||
typeof window !== 'undefined'
|
private isLatestMouseDownInsideInput = false;
|
||||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
dropdownID = `dropdown-${Dropdown.nextID++}`;
|
||||||
: false,
|
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;
|
||||||
constructor(private el: ElementRef) {}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const opts = this.normalizedOptions();
|
const opts = this.normalizedOptions();
|
||||||
@@ -65,6 +122,17 @@ export class Dropdown implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollToIndex(index: number) {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
|
const items = this.optionItems();
|
||||||
|
if (items[index]) {
|
||||||
|
items[index].nativeElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
normalizedOptions = computed(() => {
|
normalizedOptions = computed(() => {
|
||||||
return this.options().map((opt) => {
|
return this.options().map((opt) => {
|
||||||
if (typeof opt === 'string') {
|
if (typeof opt === 'string') {
|
||||||
@@ -74,15 +142,78 @@ export class Dropdown implements OnInit {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
filteredOptions = computed(() => {
|
||||||
|
const query = this.searchQuery().toLowerCase().trim().normalize('NFKD');
|
||||||
|
const opts = this.normalizedOptions();
|
||||||
|
|
||||||
|
if (!this.inputFocusMode()) return opts;
|
||||||
|
if (!query) return opts;
|
||||||
|
|
||||||
|
return opts.filter((o) => o.label.toLowerCase().normalize('NFKD').includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.isOpen.update((v) => !v);
|
if (this.isLatestMouseDownInsideInput) {
|
||||||
|
this.isLatestMouseDownInsideInput = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.searchQuery.set('');
|
||||||
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
|
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
let wasFocusVisible = this.element.nativeElement.matches(':focus-visible');
|
||||||
|
this.focusSearchInput();
|
||||||
|
|
||||||
|
if (wasFocusVisible) this.inputFocusMode.set(true);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onInputFocusIn() {
|
||||||
|
if (this.isOpen()) this.element.nativeElement.tabIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputFocusOut() {
|
||||||
|
if (this.isOpen()) this.element.nativeElement.tabIndex = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocusIn(event: FocusEvent) {
|
||||||
|
if (this.element.nativeElement.contains(event.relatedTarget)) return;
|
||||||
|
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.focusSearchInput();
|
||||||
|
this.inputFocusMode.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusSearchInput() {
|
||||||
|
const searchInputElement = this.searchInput()!.nativeElement;
|
||||||
|
|
||||||
|
searchInputElement.focus();
|
||||||
|
this.setCursorToEnd(searchInputElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkIfInputFocus(event: Event) {
|
||||||
|
let triggerInputFocus = this.searchInput()!.nativeElement.contains(
|
||||||
|
event.target! as HTMLElement,
|
||||||
|
);
|
||||||
|
return this.isOpen() && triggerInputFocus;
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
|
||||||
|
const isDropdownOpen = this.isOpen();
|
||||||
|
|
||||||
this.isOpen.set(false);
|
this.isOpen.set(false);
|
||||||
|
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
|
||||||
|
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
|
||||||
|
|
||||||
|
this.element.nativeElement.tabIndex = undefined;
|
||||||
this.focusedIndex.set(-1);
|
this.focusedIndex.set(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,63 +223,150 @@ export class Dropdown implements OnInit {
|
|||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnter(event: Event) {
|
onConfirm(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
const currentIndex = this.focusedIndex();
|
const currentIndex = this.focusedIndex();
|
||||||
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
const options = this.filteredOptions();
|
||||||
this.selectOption(this.normalizedOptions()[currentIndex]);
|
if (currentIndex >= 0 && currentIndex < options.length) {
|
||||||
|
this.selectOption(options[currentIndex]);
|
||||||
|
} else if (options.length === 0) {
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEnter(event: Event) {
|
||||||
|
this.onConfirm(event);
|
||||||
|
}
|
||||||
|
|
||||||
onSpaceDown(event: Event) {
|
onSpaceDown(event: Event) {
|
||||||
event.preventDefault();
|
if (this.isOpen() && this.inputFocusMode()) return;
|
||||||
if (this.isOpen()) {
|
this.onConfirm(event);
|
||||||
const currentIndex = this.focusedIndex();
|
|
||||||
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
|
||||||
this.selectOption(this.normalizedOptions()[currentIndex]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.toggle();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onArrowDown(event: Event) {
|
onArrowDown(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.focusedIndex.update((i) => {
|
this.focusedIndex.update((i) => {
|
||||||
|
const options = this.filteredOptions();
|
||||||
|
if (options.length === 0) return -1;
|
||||||
|
|
||||||
if (i === -1) return 0;
|
if (i === -1) return 0;
|
||||||
return (i + 1) % this.normalizedOptions().length;
|
return (i + 1) % options.length;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onArrowUp(event: Event) {
|
onArrowUp(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.focusedIndex.update((i) => {
|
this.focusedIndex.update((i) => {
|
||||||
if (i <= 0) return this.normalizedOptions().length - 1;
|
const options = this.filteredOptions();
|
||||||
|
if (options.length === 0) return -1;
|
||||||
|
|
||||||
|
if (i <= 0) return options.length - 1;
|
||||||
return i - 1;
|
return i - 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
onKeyDown(event: KeyboardEvent) {
|
||||||
onDocumentClick(event: MouseEvent) {
|
const textArrowKeys = ['ArrowLeft', 'ArrowRight'];
|
||||||
if (!this.el.nativeElement.contains(event.target)) {
|
|
||||||
this.close();
|
if (!textArrowKeys.includes(event.key)) return;
|
||||||
|
if (!this.isOpen() || this.inputFocusMode()) return;
|
||||||
|
|
||||||
|
this.inputFocusMode.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputMouseDown() {
|
||||||
|
this.isLatestMouseDownInsideInput = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseDown(event: MouseEvent) {
|
||||||
|
this.isLatestMouseDownInsideContainer = this.element.nativeElement.contains(event.target);
|
||||||
|
|
||||||
|
if (this.checkIfInputFocus(event)) {
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocumentClick(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
!this.element.nativeElement.contains(event.target) &&
|
||||||
|
!this.isLatestMouseDownInsideContainer &&
|
||||||
|
this.isOpen()
|
||||||
|
) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLatestMouseDownInsideInput = false;
|
||||||
|
this.isLatestMouseDownInsideContainer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputClick(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.isLatestMouseDownInsideInput = false;
|
||||||
|
this.isLatestMouseDownInsideContainer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearch(event: Event) {
|
||||||
|
if (!this.isOpen()) return;
|
||||||
|
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||||
|
|
||||||
|
const element = event.target as HTMLElement;
|
||||||
|
const text = element.innerText;
|
||||||
|
|
||||||
|
if (text.trim().length <= 0) element.innerHTML = '';
|
||||||
|
this.focusedIndex.set(0);
|
||||||
|
this.searchQuery.set(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
preprocessInputContent(event: InputEvent) {
|
||||||
|
if (event.inputType === 'insertLineBreak' || event.inputType === 'insertParagraph') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.inputType === 'insertFromPaste') {
|
||||||
|
const originalText = event.dataTransfer!.getData('text/plain');
|
||||||
|
const modifiedText = originalText.replace(/[\r\n]+/g, '');
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// noinspection JSDeprecatedSymbols
|
||||||
|
document.execCommand('insertText', false, modifiedText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedOption = computed(() => {
|
||||||
|
return this.normalizedOptions().find((o) => o.value === this.value());
|
||||||
|
})
|
||||||
|
|
||||||
selectedLabel = computed(() => {
|
selectedLabel = computed(() => {
|
||||||
const selected = this.normalizedOptions().find((o) => o.value === this.value());
|
const selected = this.selectedOption();
|
||||||
return selected ? selected.label : this.placeholder();
|
return selected ? selected.label : this.placeholder();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private setCursorToEnd(element: HTMLElement) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
range.collapse(false);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<section class="information">
|
||||||
|
<div class="soundtrack">
|
||||||
|
<span class="artist">{{ artist() }}</span>
|
||||||
|
<span class="track-name">{{ trackName() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="other">
|
||||||
|
<div class="creator">
|
||||||
|
<span>by</span>
|
||||||
|
<span>{{ creatorString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="buttons">
|
||||||
|
<button [aria-label]="previewButtonLabel()">
|
||||||
|
<div class="material-symbols-outlined icon-40 button-icon">play_arrow</div>
|
||||||
|
</button>
|
||||||
|
<button [aria-label]="downloadButtonLabel()">
|
||||||
|
<div class="material-symbols-outlined icon-40 button-icon">download</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$default_animation_duration: 0.2s;
|
||||||
|
|
||||||
|
@property --hover-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
height: 160px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
||||||
|
// overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline-offset: 0;
|
||||||
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.information {
|
||||||
|
width: calc(100% - 64px);
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
z-index: 1;
|
||||||
|
background-color: light-dark(white, black);
|
||||||
|
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
// outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 1px solid;
|
||||||
|
|
||||||
|
.soundtrack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 20px;
|
||||||
|
align-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 22px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.other {
|
||||||
|
display: flex;
|
||||||
|
width: 312px;
|
||||||
|
height: 68px;
|
||||||
|
padding: 3px 0;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.creator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 20px;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
align-content: center;
|
||||||
|
&:nth-child(2) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-self: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
border: none;
|
||||||
|
transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing;
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklch,
|
||||||
|
light-dark(oklch(1 0 0), oklch(0 0 0)),
|
||||||
|
light-dark(oklch(0.9702 0 0), oklch(0.25 0 0))
|
||||||
|
calc(var(--hover-state) * 100%)
|
||||||
|
);
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
&:hover {
|
||||||
|
--hover-state: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:focus-visible) {
|
||||||
|
box-shadow: -1px 0 0 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
z-index: 11;
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.button-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'level-card',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './level-card.html',
|
||||||
|
styleUrl: './level-card.scss',
|
||||||
|
host: {
|
||||||
|
role: 'article',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class LevelCard {
|
||||||
|
private translationSvc = inject(TranslationService);
|
||||||
|
protected translation = this.translationSvc.translation;
|
||||||
|
|
||||||
|
artist = input.required<string>();
|
||||||
|
trackName = input.required<string>();
|
||||||
|
creator = input<string[]>([]);
|
||||||
|
|
||||||
|
creatorString = computed(() => this.creator().join(' & '));
|
||||||
|
|
||||||
|
previewButtonLabel = computed(() =>
|
||||||
|
this.translationSvc.interpolate(this.translation.levelCard.preview, {
|
||||||
|
artist: this.artist(),
|
||||||
|
trackName: this.trackName(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
downloadButtonLabel = computed(() =>
|
||||||
|
this.translationSvc.interpolate(this.translation.levelCard.download, {
|
||||||
|
creator: this.creatorString()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
<div class="popup-backdrop" [class.center]="isCenter" (click)="onClose()">
|
<div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()">
|
||||||
<div class="popup-container"
|
<div #popupContainer class="popup-container"
|
||||||
[class]="'variant-' + variant()"
|
[class]="'variant-' + variant()"
|
||||||
[class.custom-pos]="!isCenter"
|
[class.custom-pos]="!isCenter"
|
||||||
[style.top]="customStyle.top"
|
[style.top]="customStyle.top"
|
||||||
[style.left]="customStyle.left"
|
[style.left]="customStyle.left"
|
||||||
[style.right]="customStyle.right"
|
[style.right]="customStyle.right"
|
||||||
[style.bottom]="customStyle.bottom"
|
[style.bottom]="customStyle.bottom">
|
||||||
(click)="$event.stopPropagation()">
|
|
||||||
<header class="popup-header">
|
<header class="popup-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<ng-content select="[header-left]"></ng-content>
|
<ng-content select="[header-left]"></ng-content>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()">close</button>
|
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()" [aria-label]="closeButtonLabel()">close</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
$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 {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-backdrop {
|
.popup-backdrop {
|
||||||
@@ -24,13 +34,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
animation: popup-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
animation: popup-enter var(--animation-duration) $animation_timing;
|
||||||
|
|
||||||
border: oklch(0 0 0 / 20%) 2px solid;
|
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
||||||
gap: 15px;
|
gap: 12px;
|
||||||
|
|
||||||
&.variant-glass {
|
&.variant-glass {
|
||||||
background-color: oklch(1 0 0 / 60%);
|
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +52,22 @@
|
|||||||
&.custom-pos {
|
&.custom-pos {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline-offset: 0;
|
||||||
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes popup-enter {
|
@keyframes popup-enter {
|
||||||
from {
|
from {
|
||||||
transform: scale(0.95) translateY(10px);
|
transform: scale(0.95) translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: scale(1) translateY(0);
|
transform: scale(1) translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +78,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-bottom: 1px solid oklch(0 0 0 / 5%);
|
box-shadow: 0 1px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -71,17 +88,29 @@
|
|||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
--close-hover-state: 0;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: oklch(0.4 0 0);
|
|
||||||
transition: background-color 0.2s, color 0.2s;
|
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 {
|
&:hover {
|
||||||
background-color: oklch(0 0 0 / 5%);
|
--close-hover-state: 1;
|
||||||
color: oklch(0 0 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Component, input, output, HostListener } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
HostListener,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
viewChild,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
export type PopupPosition = {
|
export type PopupPosition = {
|
||||||
top?: string;
|
top?: string;
|
||||||
@@ -15,12 +26,16 @@ export type PopupVariant = 'glass' | 'solid';
|
|||||||
templateUrl: './popup.html',
|
templateUrl: './popup.html',
|
||||||
styleUrl: './popup.scss',
|
styleUrl: './popup.scss',
|
||||||
host: {
|
host: {
|
||||||
'role': 'dialog',
|
role: 'dialog',
|
||||||
'aria-modal': 'true',
|
'aria-modal': 'true',
|
||||||
'tabindex': '-1'
|
tabindex: '-1',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
export class PopupComponent {
|
export class PopupComponent implements OnInit, OnDestroy {
|
||||||
|
private translation = inject(TranslationService).translation;
|
||||||
|
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
|
||||||
|
private previouslyFocusedElement: HTMLElement | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업의 위치 (기본값은 'center')
|
* 팝업의 위치 (기본값은 'center')
|
||||||
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
|
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
|
||||||
@@ -32,14 +47,102 @@ export class PopupComponent {
|
|||||||
*/
|
*/
|
||||||
variant = input<PopupVariant>('glass');
|
variant = input<PopupVariant>('glass');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
|
||||||
|
*/
|
||||||
|
closeButtonLabel = input<string>(this.translation.common.close);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
|
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
|
||||||
*/
|
*/
|
||||||
closeRequest = output<void>();
|
closeRequest = output<void>();
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
constructor(private element: ElementRef) {}
|
||||||
onKeydownHandler() {
|
|
||||||
this.onClose();
|
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() {
|
onClose() {
|
||||||
|
|||||||
@@ -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,33 +2,53 @@
|
|||||||
@use "sass:list";
|
@use "sass:list";
|
||||||
@use "styles/library" as library;
|
@use "styles/library" as library;
|
||||||
|
|
||||||
$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);
|
$dimension: (57px, 30px);
|
||||||
$switch_slider_dimension: (24px, 24px);
|
$knob_dimension: (24px, 24px);
|
||||||
$switch_padding: (3px, 3px);
|
$internal_padding: (3px, 3px);
|
||||||
$switch_border_radius: 15px;
|
|
||||||
$switch_border_color: oklch(0 0 0 / 20%);
|
|
||||||
|
|
||||||
$switch_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
$border_radius: 15px;
|
||||||
$switch_animation_duration: 0.2s;
|
$border_color: oklch(0 0 0 / 20%);
|
||||||
|
$border_dark_color: oklch(1 0 0 / 40%);
|
||||||
|
|
||||||
|
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
$default_animation_duration: 0.2s;
|
||||||
|
|
||||||
|
@property --animation-state {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: list.nth($switch_dimension, 1); height: list.nth($switch_dimension, 2);
|
width: list.nth($dimension, 1); height: list.nth($dimension, 2);
|
||||||
border-radius: $switch_border_radius;
|
border-radius: $border_radius;
|
||||||
background-color: $switch_off_bg;
|
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklch,
|
||||||
|
light-dark($off_bg, $off_dark_bg),
|
||||||
|
light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6))
|
||||||
|
calc(var(--animation-state) * 100%)
|
||||||
|
);
|
||||||
|
|
||||||
|
box-shadow: 0 0 0 1px color-mix(
|
||||||
|
in oklch,
|
||||||
|
light-dark($border_color, $border_dark_color),
|
||||||
|
light-dark($border_color, list.nth(library.$brand_palette, 9))
|
||||||
|
calc(var(--animation-state) * 100%)
|
||||||
|
) inset;
|
||||||
|
|
||||||
|
--animation-state: 0;
|
||||||
|
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||||
|
// transition: background-color var(--animation-duration) $animation_timing, box-shadow var(--animation-duration) $animation_timing;
|
||||||
|
transition: --animation-state var(--animation-duration) $animation_timing;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 0 0 1px $switch_border_color inset;
|
|
||||||
|
|
||||||
--switch-duration: #{$switch_animation_duration};
|
|
||||||
transition: background-color var(--switch-duration) $switch_animation_timing;
|
|
||||||
|
|
||||||
&.reduce-motion {
|
|
||||||
--switch-duration: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover, &:focus-within {
|
&:hover, &:focus-within {
|
||||||
.switch-knob {
|
.switch-knob {
|
||||||
@@ -37,12 +57,12 @@ $switch_animation_duration: 0.2s;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: list.nth(library.$brand_palette, 5);
|
--animation-state: 1;
|
||||||
|
|
||||||
.switch-knob {
|
.switch-knob {
|
||||||
transform: translate(
|
transform: translate(
|
||||||
list.nth($switch_dimension, 1) - list.nth($switch_slider_dimension, 1) - list.nth($switch_padding, 1) * 2,
|
list.nth($dimension, 1) - list.nth($knob_dimension, 1) - list.nth($internal_padding, 1) * 2,
|
||||||
list.nth($switch_dimension, 2) - list.nth($switch_slider_dimension, 2) - list.nth($switch_padding, 2) * 2
|
list.nth($dimension, 2) - list.nth($knob_dimension, 2) - list.nth($internal_padding, 2) * 2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,10 +70,10 @@ $switch_animation_duration: 0.2s;
|
|||||||
|
|
||||||
.switch-knob {
|
.switch-knob {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: list.nth($switch_slider_dimension, 1); height: list.nth($switch_slider_dimension, 2);
|
width: list.nth($knob_dimension, 1); height: list.nth($knob_dimension, 2);
|
||||||
left: list.nth($switch_padding, 1); top: list.nth($switch_padding, 2);
|
left: list.nth($internal_padding, 1); top: list.nth($internal_padding, 2);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: transform var(--switch-duration) $switch_animation_timing;
|
transition: transform var(--animation-duration) $animation_timing;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, model, output } from '@angular/core';
|
import { Component, model, output } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'switch',
|
selector: 'switch',
|
||||||
@@ -10,7 +10,6 @@ import { Component, input, model, output } from '@angular/core';
|
|||||||
tabindex: '0',
|
tabindex: '0',
|
||||||
'[attr.aria-checked]': 'isActive()',
|
'[attr.aria-checked]': 'isActive()',
|
||||||
'[class.active]': 'isActive()',
|
'[class.active]': 'isActive()',
|
||||||
'[class.reduce-motion]': 'reduceMotion()',
|
|
||||||
'(click)': 'onClick()',
|
'(click)': 'onClick()',
|
||||||
'(keydown.enter)': 'onClick()',
|
'(keydown.enter)': 'onClick()',
|
||||||
'(keydown.space)': 'onSpaceDown($event)',
|
'(keydown.space)': 'onSpaceDown($event)',
|
||||||
@@ -20,12 +19,6 @@ export class Switch {
|
|||||||
isActive = model<boolean>(false);
|
isActive = model<boolean>(false);
|
||||||
toggle = output<boolean>();
|
toggle = output<boolean>();
|
||||||
|
|
||||||
reduceMotion = input<boolean>(
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
||||||
: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
onClick(): void {
|
onClick(): void {
|
||||||
this.isActive.update((value) => !value);
|
this.isActive.update((value) => !value);
|
||||||
this.onToggle();
|
this.onToggle();
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
<div class="menu-left">
|
<div #menuLeft class="menu-left">
|
||||||
<a class="brand" [routerLink]="['/']">
|
<a class="brand" [routerLink]="['/']">
|
||||||
<img ngSrc="/logo_inverted.svg" alt="logo" height="20" width="59.52" />
|
<img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
|
||||||
<span><strong>ADOFAI Editor Forum</strong></span>
|
<span #brandText [class.hidden]="expandStage() < 2">
|
||||||
|
<strong>ADOFAI Editor Forum</strong>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.levels }}</span>
|
<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.community }}</span>
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-right">
|
<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>
|
<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"><strong>{{ translation.menu.login }}</strong></span>
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.signup }}</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">
|
<button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
|
||||||
settings
|
settings
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
|
@use "sass:map";
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: start;
|
justify-content: space-between;
|
||||||
gap: 30px;
|
gap: 0;
|
||||||
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-bottom: oklch(0 0 0 / 20%) 2px solid;
|
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
background-color: oklch(1 0 0 / 60%);
|
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
|
||||||
|
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
|
|
||||||
@@ -24,11 +27,24 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 48px;
|
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;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item, a:not(.settings) {
|
.menu-item, a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -40,10 +56,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
&.nav-link {
|
&.nav-link {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: oklch(0.5 0.15 250);
|
color: oklch(0.6 0.15 250);
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -60,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings {
|
.settings, .menu {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
@@ -69,6 +86,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -76,6 +96,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
top: -100000px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 구체적인 아이콘 예외 처리 */
|
/* 구체적인 아이콘 예외 처리 */
|
||||||
.icon-40 {
|
.icon-40 {
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -83,6 +111,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-left {
|
.menu-left {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
||||||
@@ -102,7 +133,7 @@
|
|||||||
img {
|
img {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 10px 10px 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
@@ -110,15 +141,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
/*text-box-trim: trim-both;
|
display: inline-block;
|
||||||
text-box-edge: cap alphabetic;*/
|
padding-left: 10px;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
top: -100000px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-right {
|
.menu-right {
|
||||||
flex-grow: 1;
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px 12px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,165 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
NgZone,
|
||||||
|
OnDestroy,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
|
||||||
import { PopupService } from '../../services/popup.service';
|
import { PopupService } from '../../services/popup.service';
|
||||||
import { SettingsPopup } from '../settings/settings';
|
import { SettingsPopup } from '../settings/settings';
|
||||||
|
import { NavigationMenu } from './navigation-menu';
|
||||||
import { TranslationService } from '../../services/translation.service';
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
import { SettingsService } from '../../services/settings.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'header-menu',
|
selector: 'header-menu',
|
||||||
imports: [RouterLink, NgOptimizedImage],
|
imports: [RouterLink],
|
||||||
templateUrl: './header-menu.html',
|
templateUrl: './header-menu.html',
|
||||||
styleUrl: './header-menu.scss',
|
styleUrl: './header-menu.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'[class.folded-menu]': 'expandStage() < 1',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class HeaderMenu {
|
export class HeaderMenu implements OnDestroy {
|
||||||
private popupService = inject(PopupService);
|
private popupService = inject(PopupService);
|
||||||
protected translation = inject(TranslationService).translation;
|
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() {
|
onSettingsClick() {
|
||||||
console.log('HeaderMenu: Settings clicked');
|
this.popupService.open(SettingsPopup, { position: { top: '84px', right: '20px' } });
|
||||||
this.popupService.open(SettingsPopup, { position: { top: '82px', right: '18px' } });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<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.userName() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-content">
|
|
||||||
<section class="settings-category">
|
|
||||||
<label class="category-header">{{ translation.settings.language }}</label>
|
|
||||||
|
|
||||||
<div class="settings-container">
|
|
||||||
<dropdown [options]="languages"
|
|
||||||
[placeholder]="translation.settings.chooseLanguage"
|
|
||||||
[value]="settingsService.language()"
|
|
||||||
(selectionChange)="onLanguageChange($event)"
|
|
||||||
icon="language"></dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-container">
|
|
||||||
<div class="settings-name">
|
|
||||||
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span>
|
|
||||||
<span>{{ translation.settings.romanisation }}</span>
|
|
||||||
</div>
|
|
||||||
<switch [isActive]="settingsService.romanisation()" (toggle)="onRomanisationChange($event)"></switch>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-category">
|
|
||||||
<label class="category-header">{{ translation.settings.appearance }}</label>
|
|
||||||
<div class="settings-container">
|
|
||||||
<div class="settings-name">
|
|
||||||
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span>
|
|
||||||
<span>{{ translation.settings.darkMode }}</span>
|
|
||||||
</div>
|
|
||||||
<switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</app-popup>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
: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%;
|
|
||||||
border: 1px solid oklch(0 0 0 / 20%);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
padding-bottom: 8px;
|
|
||||||
|
|
||||||
section {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 18px;
|
|
||||||
|
|
||||||
&.settings-icon {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 24px;
|
|
||||||
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
line-height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdown {
|
|
||||||
width: 324px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject, input } from '@angular/core';
|
import { Component, inject, input, output } from '@angular/core';
|
||||||
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
|
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
|
||||||
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
|
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
|
||||||
import { Switch } from '../../components/switch/switch';
|
import { Switch } from '../../components/switch/switch';
|
||||||
@@ -7,12 +7,59 @@ import { PopupService } from '../../services/popup.service';
|
|||||||
import { SettingsService, Language } from '../../services/settings.service';
|
import { SettingsService, Language } from '../../services/settings.service';
|
||||||
import { TranslationService } from '../../services/translation.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({
|
@Component({
|
||||||
selector: 'settings-popup',
|
selector: 'settings-popup',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [PopupComponent, Dropdown, Switch, NgOptimizedImage],
|
imports: [
|
||||||
templateUrl: './popup.html',
|
PopupComponent,
|
||||||
styleUrl: './popup.scss',
|
NgOptimizedImage,
|
||||||
|
SettingsCategory,
|
||||||
|
SettingsContainer,
|
||||||
|
],
|
||||||
|
templateUrl: './settings.html',
|
||||||
|
styleUrl: './settings.scss',
|
||||||
})
|
})
|
||||||
export class SettingsPopup {
|
export class SettingsPopup {
|
||||||
private popupService = inject(PopupService);
|
private popupService = inject(PopupService);
|
||||||
@@ -42,10 +89,16 @@ export class SettingsPopup {
|
|||||||
this.settingsService.setRomanisation(value);
|
this.settingsService.setRomanisation(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onReduceAnimationChange(value: boolean) {
|
||||||
|
this.settingsService.setReduceAnimation(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
|
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
|
||||||
*/
|
*/
|
||||||
close() {
|
close() {
|
||||||
this.popupService.close(SettingsPopup);
|
this.popupService.close(SettingsPopup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly SettingsType = SettingsType;
|
||||||
}
|
}
|
||||||
+11
-4
@@ -1,12 +1,12 @@
|
|||||||
export const EN = {
|
export const EN = {
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
|
guest: 'Guest',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
appearance: 'Appearance',
|
appearance: 'Appearance',
|
||||||
darkMode: 'Dark Mode',
|
darkMode: 'Dark Mode',
|
||||||
romanisation: 'Romanisation',
|
romanisation: 'Romanisation',
|
||||||
guest: 'Guest',
|
reduceAnimation: 'Reduce Animation',
|
||||||
chooseLanguage: 'Choose Language'
|
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
levels: 'Levels',
|
levels: 'Levels',
|
||||||
@@ -16,11 +16,18 @@ export const EN = {
|
|||||||
knownIssues: 'Known Issues',
|
knownIssues: 'Known Issues',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
signup: 'Signup',
|
signup: 'Signup',
|
||||||
|
menu: 'Menu',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
selectOption: 'Select Option...',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
save: 'Save'
|
save: 'Save',
|
||||||
}
|
skipToContent: 'Skip to main content',
|
||||||
|
},
|
||||||
|
levelCard: {
|
||||||
|
preview: '{{trackName}} by {{artist}} Preview',
|
||||||
|
download: "{{creator}}'s Level Download",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Translation = typeof EN;
|
export type Translation = typeof EN;
|
||||||
+9
-2
@@ -3,12 +3,12 @@ import { Translation } from './en';
|
|||||||
export const JP: Translation = {
|
export const JP: Translation = {
|
||||||
settings: {
|
settings: {
|
||||||
title: '設定',
|
title: '設定',
|
||||||
|
guest: 'ゲスト',
|
||||||
language: '言語',
|
language: '言語',
|
||||||
appearance: '外観',
|
appearance: '外観',
|
||||||
darkMode: 'ダークモード',
|
darkMode: 'ダークモード',
|
||||||
romanisation: 'ローマ字表記',
|
romanisation: 'ローマ字表記',
|
||||||
guest: 'ゲスト',
|
reduceAnimation: 'アニメーションを減らす',
|
||||||
chooseLanguage: '言語を選択',
|
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
levels: 'レベル',
|
levels: 'レベル',
|
||||||
@@ -18,9 +18,16 @@ export const JP: Translation = {
|
|||||||
knownIssues: '既知の問題',
|
knownIssues: '既知の問題',
|
||||||
login: 'ログイン',
|
login: 'ログイン',
|
||||||
signup: '新規登録',
|
signup: '新規登録',
|
||||||
|
menu: 'メニュー',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
selectOption: 'オプションを選択...',
|
||||||
close: '閉じる',
|
close: '閉じる',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
|
skipToContent: '本文へスキップ',
|
||||||
|
},
|
||||||
|
levelCard: {
|
||||||
|
preview: '{{artist}}の{{trackName}} プレビュー',
|
||||||
|
download: '{{creator}}のレベルをダウンロード',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+9
-2
@@ -3,12 +3,12 @@ import { Translation } from './en';
|
|||||||
export const KR: Translation = {
|
export const KR: Translation = {
|
||||||
settings: {
|
settings: {
|
||||||
title: '설정',
|
title: '설정',
|
||||||
|
guest: '게스트',
|
||||||
language: '언어',
|
language: '언어',
|
||||||
appearance: '화면 설정',
|
appearance: '화면 설정',
|
||||||
darkMode: '다크 모드',
|
darkMode: '다크 모드',
|
||||||
romanisation: '로마자 표기',
|
romanisation: '로마자 표기',
|
||||||
guest: '게스트',
|
reduceAnimation: '애니메이션 줄이기',
|
||||||
chooseLanguage: '언어 선택',
|
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
levels: '레벨',
|
levels: '레벨',
|
||||||
@@ -18,9 +18,16 @@ export const KR: Translation = {
|
|||||||
knownIssues: '알려진 문제',
|
knownIssues: '알려진 문제',
|
||||||
login: '로그인',
|
login: '로그인',
|
||||||
signup: '회원가입',
|
signup: '회원가입',
|
||||||
|
menu: '메뉴',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
|
selectOption: '옵션 선택...',
|
||||||
close: '닫기',
|
close: '닫기',
|
||||||
save: '저장',
|
save: '저장',
|
||||||
|
skipToContent: '본문으로 건너뛰기',
|
||||||
|
},
|
||||||
|
levelCard: {
|
||||||
|
preview: '{{artist}}의 {{trackName}} 미리듣기',
|
||||||
|
download: '{{creator}}의 레벨 다운로드',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="container">
|
<brand-accent></brand-accent>
|
||||||
<brand-accent></brand-accent>
|
|
||||||
|
|
||||||
<h1>AEF Component Test</h1>
|
<div class="container">
|
||||||
|
<h1>Component Test</h1>
|
||||||
|
|
||||||
<section id="switch-test" class="feature-block">
|
<section id="switch-test" class="feature-block">
|
||||||
<h2>Switch Component</h2>
|
<h2>Switch Component</h2>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:host > .container {
|
:host > .container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 600px;
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
@@ -8,7 +18,8 @@
|
|||||||
|
|
||||||
section#dropdown-test {
|
section#dropdown-test {
|
||||||
dropdown {
|
dropdown {
|
||||||
width: 324px;
|
width: 100%;
|
||||||
|
max-width: 324px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +46,12 @@ div.test-container {
|
|||||||
brand-accent {
|
brand-accent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
width: 100%;
|
top: -64px;
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100dvw;
|
||||||
|
height: calc(100% + 64px);
|
||||||
|
min-height: 100dvh;
|
||||||
z-index: -999999;
|
z-index: -999999;
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,43 @@ export class MainPage {
|
|||||||
{ label: 'English', value: 'EN' },
|
{ label: 'English', value: 'EN' },
|
||||||
{ label: '한국어', value: 'KR' },
|
{ label: '한국어', value: 'KR' },
|
||||||
{ label: '日本語', value: 'JP' },
|
{ 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);
|
selectedDropdownValue = signal<any>(null);
|
||||||
|
|||||||
@@ -12,15 +12,18 @@ export class SettingsService {
|
|||||||
|
|
||||||
// Signals for state
|
// Signals for state
|
||||||
private _language = signal<Language>('EN');
|
private _language = signal<Language>('EN');
|
||||||
private _darkMode = signal<boolean>(true);
|
private _darkMode = signal<boolean>(false);
|
||||||
private _romanisation = signal<boolean>(false);
|
private _romanisation = signal<boolean>(true);
|
||||||
private _userName = signal<string>('Guest');
|
private _userName = signal<string>('Guest');
|
||||||
|
private _reduceAnimation = signal<boolean>(false);
|
||||||
|
|
||||||
// Public computed or read-only signals
|
// Public computed or read-only signals
|
||||||
language = computed(() => this._language());
|
language = computed(() => this._language());
|
||||||
darkMode = computed(() => this._darkMode());
|
darkMode = computed(() => this._darkMode());
|
||||||
romanisation = computed(() => this._romanisation());
|
romanisation = computed(() => this._romanisation());
|
||||||
userName = computed(() => this._userName());
|
userName = computed(() => this._userName());
|
||||||
|
reduceAnimation = computed(() => this._reduceAnimation());
|
||||||
|
isGuest = computed(() => true);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.request = inject(REQUEST);
|
this.request = inject(REQUEST);
|
||||||
@@ -28,30 +31,54 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initializeSettings() {
|
private initializeSettings() {
|
||||||
|
let parsedCookie: Record<string, string>;
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
// 클라이언트: 브라우저 API 사용
|
parsedCookie = this.parseCookies(document.cookie);
|
||||||
this._language.set((this.getCookie('lang') as Language) || 'EN');
|
|
||||||
this._darkMode.set(this.getCookie('darkMode') !== 'false');
|
|
||||||
this._romanisation.set(this.getCookie('romanisation') === 'true');
|
|
||||||
this._userName.set(this.getCookie('userName') || 'Guest');
|
|
||||||
} else if (isPlatformServer(this.platformId)) {
|
} else if (isPlatformServer(this.platformId)) {
|
||||||
const cookieHeader = this.request?.headers.get('Cookie');
|
const cookieHeader = this.request?.headers.get('Cookie');
|
||||||
|
|
||||||
if (cookieHeader) {
|
if (!cookieHeader) return;
|
||||||
const cookies = this.parseServerCookies(cookieHeader);
|
parsedCookie = this.parseCookies(cookieHeader);
|
||||||
|
} else return;
|
||||||
|
|
||||||
// console.log(cookies);
|
const settings = {
|
||||||
if (cookies['lang']) this._language.set(cookies['lang'] as Language);
|
lang: parsedCookie['lang'],
|
||||||
if (cookies['darkMode'] !== undefined) this._darkMode.set(cookies['darkMode'] !== 'false');
|
darkMode: parsedCookie['darkMode'],
|
||||||
if (cookies['romanisation'] !== undefined) this._romanisation.set(cookies['romanisation'] === 'true');
|
romanisation: parsedCookie['romanisation'],
|
||||||
if (cookies['userName']) this._userName.set(cookies['userName']);
|
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 parseServerCookies(cookieString: string): Record<string, string> {
|
private parseCookies(cookieString: string): Record<string, string> {
|
||||||
const cookies: Record<string, string> = {};
|
const cookies: Record<string, string> = {};
|
||||||
cookieString.split(';').forEach(c => {
|
cookieString.split(';').forEach((c) => {
|
||||||
const [key, value] = c.trim().split('=');
|
const [key, value] = c.trim().split('=');
|
||||||
if (key && value) cookies[key] = decodeURIComponent(value);
|
if (key && value) cookies[key] = decodeURIComponent(value);
|
||||||
});
|
});
|
||||||
@@ -78,14 +105,9 @@ export class SettingsService {
|
|||||||
this.setCookie('userName', name);
|
this.setCookie('userName', name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCookie(name: string): string | undefined {
|
setReduceAnimation(value: boolean) {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
this._reduceAnimation.set(value);
|
||||||
const value = `; ${document.cookie}`;
|
this.setCookie('reduceAnimation', value.toString());
|
||||||
const parts = value.split(`; ${name}=`);
|
|
||||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCookie(name: string, value: string) {
|
private setCookie(name: string, value: string) {
|
||||||
|
|||||||
@@ -29,4 +29,14 @@ export class TranslationService {
|
|||||||
readonly translation = new Proxy({} as Translation, {
|
readonly translation = new Proxy({} as Translation, {
|
||||||
get: (_, prop) => this._translation()[prop as keyof 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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+37
-3
@@ -5,12 +5,42 @@
|
|||||||
@import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css');
|
@import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css');
|
||||||
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css");
|
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--motion-duration-multiplier: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.reduce-motion {
|
||||||
|
--motion-duration-multiplier: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--if-dark: initial;
|
||||||
|
|
||||||
|
&.dark-theme {
|
||||||
|
--if-dark: ;
|
||||||
|
color-scheme: dark;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
& > body {
|
||||||
|
background-color: oklch(0.1776 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.feature-block {
|
||||||
|
background-color: oklch(0 0 0 / 60%);
|
||||||
|
outline: 2px solid oklch(1 0 0 / 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif;
|
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overscroll-behavior-x: none;
|
overscroll-behavior-x: none;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-24 {
|
.icon-24 {
|
||||||
@@ -40,7 +70,7 @@ h1 {
|
|||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: oklch(0.5103 0 0); // #666
|
color: oklch(0.6830 0 0); // #999999
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -77,7 +107,7 @@ section.feature-block {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
|
outline: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
background-color: oklch(1 0 0 / 60%);
|
background-color: oklch(1 0 0 / 60%);
|
||||||
@@ -90,7 +120,11 @@ section.feature-block {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
--selection-outline-color: light-dark(#{list.nth(library.$brand_palette, 5)}, #{list.nth(library.$brand_palette, 6)});
|
||||||
|
}
|
||||||
|
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid list.nth(library.$brand_palette, 5);
|
outline: 2px solid var(--selection-outline-color);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -12,5 +12,21 @@ $brand_palette: [
|
|||||||
oklch(0.4509 0.1688 29.86), // #9E190E
|
oklch(0.4509 0.1688 29.86), // #9E190E
|
||||||
oklch(0.3897 0.1391 29.75), // #7F180F
|
oklch(0.3897 0.1391 29.75), // #7F180F
|
||||||
oklch(0.2547 0.0913 29.02), // #450805
|
oklch(0.2547 0.0913 29.02), // #450805
|
||||||
|
oklch(0.1752 0.0717 29.02), // #280000
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/*
|
||||||
|
$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),
|
||||||
|
];
|
||||||
|
*/
|
||||||
Reference in new issue
Block a user