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