Compare commits

..
74 Commits
Author SHA1 Message Date
copecone fe01a365a8 Add Renderer Scale to Brand Accent 2026-05-10 15:12:29 +09:00
copecone f68aa880db Update Skip Link Animation 2026-05-09 01:33:22 +09:00
copecone a2ec29adca Update Brand Accent to Reflect Reduce Animation 2026-05-06 08:12:26 +09:00
copecone 0908064688 Adjust Brand Accent & Dropdown 2026-05-05 16:19:31 +09:00
copecone 25a9bca291 Add Navigation Menu Component 2026-05-05 15:17:52 +09:00
copecone 7363684b2f Improve Adaptive Navigation of Header Menu 2026-05-05 13:55:29 +09:00
copecone 2f5e79e300 Fix Level Card Buttons Selection Outline 2026-05-04 13:44:41 +09:00
copecone 912bd0d755 Fix Header Menu Link Color 2026-05-04 09:58:43 +09:00
copecone e01e628d94 Improve Accessibility by Improve Level Card Button Label 2026-05-04 00:10:04 +09:00
copecone 214dfb498e Fix Skip to Main Content Button 2026-05-03 17:21:02 +09:00
copecone f343f3e977 Update Build Version 2026-05-02 18:06:48 +09:00
copecone 1a49cd132b Add Skip Links Component 2026-05-02 18:04:10 +09:00
copecone 713f0ef75b Add Popup Focus Trap Feature 2026-05-02 17:01:54 +09:00
copecone 3bece368b3 Improve Language Accessibility 2026-04-25 14:00:25 +09:00
copecone 405f2808a0 Improve Readability of Level Card 2026-04-24 14:54:36 +09:00
copecone 88d2b6a546 Add Levels to Properly Test Level Card 2026-04-24 14:15:08 +09:00
copecone e86f500d8f Implement Level Card Prototype 2026-04-24 12:38:41 +09:00
copecone 31f78de70b Add Basic Prototype of Level Card Component 2026-04-24 08:01:02 +09:00
copecone 96894c81e0 Resolve IDE Compatibility Issue 2026-04-23 14:48:15 +09:00
copecone 1885ed3926 Add Settings Container Component 2026-04-23 14:13:33 +09:00
copecone 96f62b0e7c Update Dark Mode Color Palette 2026-04-23 09:53:00 +09:00
copecone 0586005191 Improve Settings Popup Accessibility 2026-04-21 15:54:00 +09:00
copecone 9092c97b9c Update Dropdown Focus Behavior to Be More Accessible 2026-04-21 15:52:20 +09:00
copecone c21845a579 Remove Frame Delay from Focus Move Code 2026-04-20 12:03:48 +09:00
copecone 5de6d111f2 Make Dropdown to Process List Scroll Properly 2026-04-20 11:59:00 +09:00
copecone 0baba36f7f Make Dropdown to Clear Its Input History Everytime 2026-04-20 10:02:33 +09:00
copecone dceac30c39 Update Dropdown to Maintain Input Format Consistency 2026-04-20 09:06:04 +09:00
copecone 9c7f3cf542 Update Dropdown to Improve Support for Unicode Characters 2026-04-20 01:17:35 +09:00
copecone 35d20eab76 Fix Popup to Don't Interfere Other Component Interactions 2026-04-19 20:13:30 +09:00
copecone 3a415a2525 Polish Mouse Click Response 2026-04-19 19:55:12 +09:00
copecone 3f33c0dfa5 Add Dropdown Search Feature 2026-04-19 18:51:46 +09:00
copecone 7a0af6cc4a Update Build Version 2026-04-17 13:08:33 +09:00
copecone 0c97bc696d Update Some Packages 2026-04-17 13:05:27 +09:00
copecone 4605fd7ee4 Add Header Menu Brand Text Hidden Mode 2026-04-17 12:42:56 +09:00
copecone be48e8ef89 Change Brand Accent Change Detection Strategy 2026-04-17 12:42:19 +09:00
copecone 02f936b345 Improve Brand Accent Renderer 2026-04-17 11:38:34 +09:00
copecone 0801ed3c70 Remove Popup Category Label Color to Ensure Enough Contrast from Content 2026-04-12 20:37:04 +09:00
copecone b0ae9717f5 Update README.md 2026-04-10 01:55:32 -04:00
copecone ae9e86461e Fix Light Mode Brand Accent Rendering 2026-04-09 22:08:01 +09:00
copecone 94ca7defb2 Optimize Brand Accent Component By Caching Dark Mode Ellipse 2026-04-09 16:10:45 +09:00
copecone ed7ecb2994 Update Build Version 2026-04-09 08:22:23 +09:00
copecone 9fbd2db197 Optimize Brand Accent Component 2026-04-08 13:00:55 +09:00
copecone f610849c17 Add Dark Mode Support for Clock Component 2026-04-08 12:41:55 +09:00
copecone 57588d4604 Fix Color Animation Implementation 2026-04-07 16:02:58 +09:00
copecone 7cf7c49937 Fix Dropdown Item Style Implementation 2026-04-07 13:04:57 +09:00
copecone 81bfd4a2b8 Fix Popup Implementation 2026-04-07 12:04:49 +09:00
copecone 44b7d6097e Fix Dark Theme Application Architecture 2026-04-07 11:58:12 +09:00
copecone 1f5453f755 Add Dark Mode Support for Switch Component 2026-04-07 11:27:55 +09:00
copecone f97e9592ad Add Dark Mode Support for Brand Accent Component 2026-04-07 11:27:49 +09:00
copecone 4dc35c5aac Add Dark Mode Support for Global CSS 2026-04-07 11:27:40 +09:00
copecone 98d122c8ef Increase Update Rate 2026-04-05 21:48:48 +09:00
copecone 29f79678e8 Remove Safari from Browser List 2026-04-03 15:46:04 +09:00
copecone e6b6947e08 Optimize Brand Accent Component 2026-04-03 15:22:20 +09:00
copecone 33e8b61065 Add Some Responsive Features 2026-04-03 13:06:21 +09:00
copecone 83ba362c59 Fix .browserslistrc to OKLCH-Supported Browsers 2026-04-03 12:13:30 +09:00
copecone 372c5a53d7 Add Target Browser List 2026-04-03 11:44:10 +09:00
copecone 4e356289a3 Fix Animation Consistency & UX 2026-04-03 11:20:44 +09:00
copecone 9995b8c1e4 Apply Settings Container New Design 2026-04-03 10:44:48 +09:00
copecone da1789c1fe Fix JP Translation 2026-04-03 08:24:43 +09:00
copecone 436a01b455 Remove Redundant Import from switch.ts 2026-04-02 14:58:40 +09:00
copecone f2ac841dfd Remove Redundant Import from switch.ts 2026-04-02 14:55:26 +09:00
copecone 4974a699dc Add Reduce Animation Setting 2026-04-02 14:54:57 +09:00
copecone 9043aa0535 Fix Main Page Title Text 2026-04-02 11:35:53 +09:00
copecone 2d6e0995c7 Merge remote-tracking branch 'origin/main' 2026-04-02 10:17:25 +09:00
copecone 7b1b47fd05 Add Non-Selectable Property to Header Menu 2026-04-02 10:11:26 +09:00
copecone bac135853d Improve Popup Accessibility 2026-04-01 13:12:33 +09:00
copecone 7b5ba462cb Improve UI Structure Consistency 2026-04-01 13:08:14 +09:00
copecone 32911f244a Improve UI Focus Consistency 2026-04-01 12:08:03 +09:00
copecone 8c3d9e4dda Restore Dummy Link Cursor Style 2026-03-31 15:51:49 +09:00
copecone 1a09c96309 Remove Redundant Stylesheet Comments 2026-03-31 15:27:56 +09:00
copecone 69774fab76 Fix SSR using Cookies 2026-03-31 15:25:44 +09:00
copecone 5dfb1dba03 Apply Language Settings 2026-03-31 12:10:28 +09:00
copecone 39113a4d39 Merge remote-tracking branch 'origin/main' 2026-03-31 10:28:59 +09:00
copecone 68ebed341f Adjust Dropdown Component Style 2026-03-31 10:28:48 +09:00
57 changed files with 2553 additions and 497 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
Chrome >= 111
Firefox >= 113
Edge >= 111
not dead
+2 -58
View File
@@ -1,59 +1,3 @@
# AefComponentTest # Component Test
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1. For Testing Designed Components for ADOFAI Editor Forum Web
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+5
View File
@@ -45,6 +45,11 @@
}, },
"ssr": { "ssr": {
"entry": "src/server.ts" "entry": "src/server.ts"
},
"optimization": {
"scripts": true,
"styles": true,
"fonts": false
} }
}, },
"configurations": { "configurations": {
+2 -2
View File
@@ -1,11 +1,11 @@
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트 # AefComponentTest Docker 이미지 빌드 및 추출 스크립트
$IMAGE_NAME = "aef-component-test" $IMAGE_NAME = "aef-component-test"
$TAG = "0.0.4" $TAG = "0.0.9"
$OUTPUT_FILE = "aef-component-test.tar" $OUTPUT_FILE = "aef-component-test.tar"
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
docker build -t "$($IMAGE_NAME):$TAG" . docker build --no-cache -t "$($IMAGE_NAME):$TAG" .
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
Write-Host "--- 이미지 추출 시작: $OUTPUT_FILE ---" -ForegroundColor Cyan Write-Host "--- 이미지 추출 시작: $OUTPUT_FILE ---" -ForegroundColor Cyan
+179 -37
View File
@@ -310,13 +310,13 @@
} }
}, },
"node_modules/@angular-devkit/schematics": { "node_modules/@angular-devkit/schematics": {
"version": "21.2.2", "version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.2.tgz", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.7.tgz",
"integrity": "sha512-CCeyQxGUq+oyGnHd7PfcYIVbj9pRnqjQq0rAojoAqs1BJdtInx9weLBCLy+AjM3NHePeZrnwm+wEVr8apED8kg==", "integrity": "sha512-LYAjjUI1qM7pR/sd0yYt8OLA6ljOOXjcfzV40I5XQNmhAxq90YYS5xwMcixOmWX+z5zvCYGvPXvJGWjzio6SUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular-devkit/core": "21.2.2", "@angular-devkit/core": "21.2.7",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"magic-string": "0.30.21", "magic-string": "0.30.21",
"ora": "9.3.0", "ora": "9.3.0",
@@ -328,6 +328,47 @@
"yarn": ">= 1.13.0" "yarn": ">= 1.13.0"
} }
}, },
"node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": {
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"jsonc-parser": "3.3.1",
"picomatch": "4.0.4",
"rxjs": "7.8.2",
"source-map": "0.7.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
},
"peerDependencies": {
"chokidar": "^5.0.0"
},
"peerDependenciesMeta": {
"chokidar": {
"optional": true
}
}
},
"node_modules/@angular-devkit/schematics/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@angular/build": { "node_modules/@angular/build": {
"version": "21.2.2", "version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.2.tgz", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.2.tgz",
@@ -429,19 +470,19 @@
} }
}, },
"node_modules/@angular/cli": { "node_modules/@angular/cli": {
"version": "21.2.2", "version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.2.tgz", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.7.tgz",
"integrity": "sha512-eZo8/qX+ZIpIWc0CN+cCX13Lbgi/031wAp8DRVhDDO6SMVtcr/ObOQ2S16+pQdOMXxiG3vby6IhzJuz9WACzMQ==", "integrity": "sha512-N/wj8fFRB718efIFYpwnYfy+MecZREZXsUNMTVndFLH6T0jCheb9PVetR6jsyZp6h46USNPOmJYJ/9255lME+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular-devkit/architect": "0.2102.2", "@angular-devkit/architect": "0.2102.7",
"@angular-devkit/core": "21.2.2", "@angular-devkit/core": "21.2.7",
"@angular-devkit/schematics": "21.2.2", "@angular-devkit/schematics": "21.2.7",
"@inquirer/prompts": "7.10.1", "@inquirer/prompts": "7.10.1",
"@listr2/prompt-adapter-inquirer": "3.0.5", "@listr2/prompt-adapter-inquirer": "3.0.5",
"@modelcontextprotocol/sdk": "1.26.0", "@modelcontextprotocol/sdk": "1.26.0",
"@schematics/angular": "21.2.2", "@schematics/angular": "21.2.7",
"@yarnpkg/lockfile": "1.1.0", "@yarnpkg/lockfile": "1.1.0",
"algoliasearch": "5.48.1", "algoliasearch": "5.48.1",
"ini": "6.0.0", "ini": "6.0.0",
@@ -463,6 +504,66 @@
"yarn": ">= 1.13.0" "yarn": ">= 1.13.0"
} }
}, },
"node_modules/@angular/cli/node_modules/@angular-devkit/architect": {
"version": "0.2102.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.7.tgz",
"integrity": "sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": "21.2.7",
"rxjs": "7.8.2"
},
"bin": {
"architect": "bin/cli.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
}
},
"node_modules/@angular/cli/node_modules/@angular-devkit/core": {
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"jsonc-parser": "3.3.1",
"picomatch": "4.0.4",
"rxjs": "7.8.2",
"source-map": "0.7.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
},
"peerDependencies": {
"chokidar": "^5.0.0"
},
"peerDependenciesMeta": {
"chokidar": {
"optional": true
}
}
},
"node_modules/@angular/cli/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@angular/common": { "node_modules/@angular/common": {
"version": "21.2.4", "version": "21.2.4",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.4.tgz", "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.4.tgz",
@@ -629,9 +730,9 @@
} }
}, },
"node_modules/@angular/ssr": { "node_modules/@angular/ssr": {
"version": "21.2.2", "version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.2.tgz", "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.7.tgz",
"integrity": "sha512-ZEdE0G3+v0JCtSxpBynSlGImXi47HwWGqANtfHcOJUdf6GGKzSzoyliT1+7Lu2TR4TyCjtDq/RQH5bETr3zghA==", "integrity": "sha512-NhrkeD32s3H/jU9yJLqDy2JBNNatFyzqNkwieJw0waEvBRNbxXlcg5+g6rilcg2nHlH5hyzMQUzs7ZwZH9wCqg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -1660,9 +1761,9 @@
"optional": true "optional": true
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.11", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3880,14 +3981,14 @@
] ]
}, },
"node_modules/@schematics/angular": { "node_modules/@schematics/angular": {
"version": "21.2.2", "version": "21.2.7",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.2.tgz", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.7.tgz",
"integrity": "sha512-Ywa6HDtX7TRBQZTVMMnxX3Mk7yVnG8KtSFaXWrkx779+q8tqYdBwNwAqbNd4Zatr1GccKaz9xcptHJta5+DTxw==", "integrity": "sha512-aqEj3RyBtmH+41HZvrbfrpCo0e+0NzwyQyNSC/wLDShVqoidBtPbEdHU1FZ4+ni41da7rI3F12gUuAHws27kMA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular-devkit/core": "21.2.2", "@angular-devkit/core": "21.2.7",
"@angular-devkit/schematics": "21.2.2", "@angular-devkit/schematics": "21.2.7",
"jsonc-parser": "3.3.1" "jsonc-parser": "3.3.1"
}, },
"engines": { "engines": {
@@ -3896,6 +3997,47 @@
"yarn": ">= 1.13.0" "yarn": ">= 1.13.0"
} }
}, },
"node_modules/@schematics/angular/node_modules/@angular-devkit/core": {
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"jsonc-parser": "3.3.1",
"picomatch": "4.0.4",
"rxjs": "7.8.2",
"source-map": "0.7.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
},
"peerDependencies": {
"chokidar": "^5.0.0"
},
"peerDependenciesMeta": {
"chokidar": {
"optional": true
}
}
},
"node_modules/@schematics/angular/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@sigstore/bundle": { "node_modules/@sigstore/bundle": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz",
@@ -4503,9 +4645,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.4", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5671,9 +5813,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.5", "version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7125,9 +7267,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -7954,9 +8096,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/stdin-discarder": { "node_modules/stdin-discarder": {
"version": "0.3.1", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
"integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -8007,9 +8149,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.5.10", "version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
+8 -1
View File
@@ -1,5 +1,12 @@
<skip-links ngSkipHydration>
<button (click)="skipToContent()">{{ translation.common.skipToContent }}</button>
</skip-links>
<header-menu></header-menu> <header-menu></header-menu>
<router-outlet />
<main #content>
<router-outlet />
</main>
<div id="global-popup-root" class="popup-outlet"> <div id="global-popup-root" class="popup-outlet">
@for (popup of popupService.activePopups(); track popup.component) { @for (popup of popupService.activePopups(); track popup.component) {
+1 -1
View File
@@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [ export const serverRoutes: ServerRoute[] = [
{ {
path: '**', path: '**',
renderMode: RenderMode.Prerender, renderMode: RenderMode.Server,
}, },
]; ];
+2
View File
@@ -2,9 +2,11 @@ import { Routes } from '@angular/router';
import { MainPage } from './pages/main/main'; import { MainPage } from './pages/main/main';
import { KnownIssuesPage } from './pages/known-issues/known-issues'; import { KnownIssuesPage } from './pages/known-issues/known-issues';
import { ClockPage } from './pages/clock/clock'; import { ClockPage } from './pages/clock/clock';
import { LevelPage } from './pages/levels/levels';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: MainPage }, { path: '', component: MainPage },
{ path: 'known-issues', component: KnownIssuesPage }, { path: 'known-issues', component: KnownIssuesPage },
{ path: 'clock', component: ClockPage }, { path: 'clock', component: ClockPage },
{ path: 'levels', component: LevelPage },
]; ];
+7
View File
@@ -0,0 +1,7 @@
:host {
min-height: 100dvh;
}
main:focus-visible {
outline: none;
}
+52 -4
View File
@@ -1,15 +1,63 @@
import { Component, inject } from '@angular/core'; import { Component, inject, effect, PLATFORM_ID, ElementRef, viewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { HeaderMenu } from './features/header-menu/header-menu'; import { HeaderMenu } from './features/header-menu/header-menu';
import { PopupService } from './services/popup.service'; import { PopupService } from './services/popup.service';
import { NgComponentOutlet } from '@angular/common'; import { SettingsService } from './services/settings.service';
import { NgComponentOutlet, isPlatformBrowser, isPlatformServer, DOCUMENT } from '@angular/common';
import { SkipLinks } from './components/skip-links/skip-links';
import { TranslationService } from './services/translation.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet], imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss',
}) })
export class App { export class App {
protected popupService = inject(PopupService); protected popupService = inject(PopupService);
protected settingsService = inject(SettingsService);
protected translation = inject(TranslationService).translation;
private platformID = inject(PLATFORM_ID);
private contentElement = viewChild<ElementRef<HTMLElement>>("content");
private document = inject(DOCUMENT);
private rootHtmlElement!: HTMLElement;
constructor() {
if (isPlatformServer(this.platformID)) {
this.rootHtmlElement = this.document.documentElement;
} else if (isPlatformBrowser(this.platformID)) {
this.rootHtmlElement = document.documentElement;
}
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;
}
} }
+220 -44
View File
@@ -1,5 +1,17 @@
import { afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild } from '@angular/core'; import {
afterNextRender,
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
input,
NgZone,
OnDestroy,
viewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SettingsService } from '../../services/settings.service';
@Component({ @Component({
selector: 'brand-accent', selector: 'brand-accent',
@@ -7,13 +19,27 @@ import { CommonModule } from '@angular/common';
imports: [CommonModule], imports: [CommonModule],
templateUrl: './brand-accent.html', templateUrl: './brand-accent.html',
styleUrl: './brand-accent.scss', styleUrl: './brand-accent.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BrandAccent implements OnDestroy { export class BrandAccent implements OnDestroy {
@ViewChild('canvasElement') canvasRef!: ElementRef<HTMLCanvasElement>; canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('canvasElement');
private ctx!: CanvasRenderingContext2D; private ctx!: CanvasRenderingContext2D;
private resizeObserver?: ResizeObserver; private settingsService = inject(SettingsService);
drawGrid = input<boolean>(true);
private bgRendererScale = 1;
private canvasNativeSize: [number, number] = [0, 0]; private canvasNativeSize: [number, number] = [0, 0];
private bgGradient!: CanvasGradient;
private gridGradient!: CanvasGradient;
private readonly offscreenCanvas!: OffscreenCanvas;
private readonly offscreenCtx!: OffscreenCanvasRenderingContext2D;
private resizeObserver?: ResizeObserver;
private intersectionObserver?: IntersectionObserver;
private rendererScale!: number;
private animation: { private animation: {
id?: number; id?: number;
@@ -33,41 +59,166 @@ export class BrandAccent implements OnDestroy {
}; };
constructor(private ngZone: NgZone) { constructor(private ngZone: NgZone) {
if (typeof document !== 'undefined') {
this.offscreenCanvas = new OffscreenCanvas(0, 0);
this.offscreenCtx = this.offscreenCanvas.getContext('2d', { colorSpace: 'srgb' })!;
}
effect(() => {
// Trigger cache refresh when dark mode changes
const isDark = this.settingsService.darkMode();
if (this.ctx) {
this.cacheGradients(isDark);
this.cacheBackground(isDark);
}
});
afterNextRender(() => { afterNextRender(() => {
this.ctx = this.canvasRef.nativeElement.getContext('2d')!; const nativeElement = this.canvasRef()?.nativeElement;
this.ngZone.runOutsideAngular(() => { if (nativeElement) {
this.resizeObserver = new ResizeObserver(() => this.resize()); this.ctx = nativeElement.getContext('2d', { colorSpace: 'srgb' })!;
this.resizeObserver.observe(this.canvasRef.nativeElement);
this.resize(); // Safe Initialization this.ngZone.runOutsideAngular(() => {
this.animation.startTime = performance.now(); this.resizeObserver = new ResizeObserver((entries) => {
this.draw(); for (let entry of entries) {
const { inlineSize: width, blockSize: height } = entry.borderBoxSize[0];
this.resize(width, height);
}
});
this.resizeObserver.observe(nativeElement);
this.animate(performance.now()); 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() { private resize(width: number, height: number) {
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef()?.nativeElement;
const dpr = window.devicePixelRatio || 1; if (canvas) {
const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr; this.rendererScale = dpr;
canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr); canvas.width = width * dpr;
this.updateAnimationInfo(); 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() { private updateAnimationInfo() {
this.canvasNativeSize = [this.canvasRef.nativeElement.clientWidth, this.canvasRef.nativeElement.clientHeight];
this.animation.gridDimension = [ this.animation.gridDimension = [
Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3, Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3,
Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3, Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3,
]; ];
const isDark = this.settingsService.darkMode();
this.cacheGradients(isDark);
this.cacheBackground(isDark);
}
private cacheGradients(isDark: boolean) {
let w = this.canvasNativeSize[0];
let h = this.canvasNativeSize[1];
if (w === 0 || h === 0) return;
if (!isDark) {
this.bgGradient = this.ctx.createLinearGradient(
0,
0,
w * this.bgRendererScale,
h * this.bgRendererScale,
);
this.bgGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)');
this.bgGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
}
this.gridGradient = this.ctx.createLinearGradient(0, 0, w, h);
if (isDark) {
// Dark mode: subtle light grid
this.gridGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
this.gridGradient.addColorStop(1, 'rgba(255, 255, 255, 0.4)');
} else {
// Light mode: dark grid
this.gridGradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
this.gridGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
}
}
private cacheBackground(isDark: boolean) {
if (!this.offscreenCtx) return;
const w = this.canvasNativeSize[0];
const h = this.canvasNativeSize[1];
if (w === 0 || h === 0) return;
this.offscreenCtx.clearRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
this.offscreenCtx.globalCompositeOperation = 'source-over';
if (!isDark) {
this.offscreenCtx.fillStyle = this.bgGradient;
this.offscreenCtx.fillRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
} else {
this.offscreenCtx.fillStyle = 'oklch(0.1776 0 0)';
this.offscreenCtx.fillRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
const ellipseDimension = [Math.max(100, w * 0.9421875), Math.max(200 / 3, h * (203 / 120))];
const ellipseX = w / 2;
const ellipseY = h * 0.5763888888 + ellipseDimension[1] / 2;
const blurScale =
Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) /
this.rendererScale;
this.offscreenCtx.filter = `blur(${blurScale * this.bgRendererScale}px)`;
this.offscreenCtx.beginPath();
this.offscreenCtx.ellipse(
ellipseX * this.bgRendererScale,
ellipseY * this.bgRendererScale,
(ellipseDimension[0] / 2) * this.bgRendererScale,
(ellipseDimension[1] / 2) * this.bgRendererScale,
0,
0,
2 * Math.PI,
);
this.offscreenCtx.fillStyle = 'oklch(0.2547 0.0913 29.02)';
this.offscreenCtx.fill();
this.offscreenCtx.filter = 'none';
}
} }
private animate = (currentTime: number) => { private animate = (currentTime: number) => {
@@ -87,50 +238,75 @@ export class BrandAccent implements OnDestroy {
private draw() { private draw() {
this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
this.ctx.globalCompositeOperation = 'source-over'; const isDark = this.settingsService.darkMode();
const linearGradient = this.ctx.createLinearGradient(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); if (this.offscreenCanvas) {
this.ctx.globalCompositeOperation = 'source-over';
linearGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)'); this.ctx.imageSmoothingEnabled = false;
linearGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)'); this.ctx.drawImage(
this.offscreenCanvas,
0,
0,
this.canvasNativeSize[0],
this.canvasNativeSize[1],
);
this.ctx.fillStyle = linearGradient; // this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
this.ctx.fillRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); }
this.ctx.globalCompositeOperation = 'multiply'; if (!this.drawGrid()) return;
const gridGradient = this.ctx.createLinearGradient(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]); if (!isDark) {
gridGradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); this.ctx.globalCompositeOperation = 'multiply';
gridGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); } else {
this.ctx.globalCompositeOperation = 'screen';
}
this.ctx.beginPath(); this.ctx.beginPath();
this.ctx.lineWidth = 2; this.ctx.lineWidth = 2;
this.ctx.strokeStyle = gridGradient; this.ctx.strokeStyle = this.gridGradient;
this.ctx.lineCap = 'round'; this.ctx.lineCap = 'round';
const lineRenderMargin = this.animation.gridSquareSize * 2; const lineRenderMargin = this.animation.gridSquareSize * 2;
const xOffset = (this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) / 2; const xOffset =
const yOffset = (this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) / 2; (this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) /
const movingOffset = -(this.animation.gridSquareSize * this.animation.progress); 2;
const yOffset =
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
2;
const movingOffset = this.settingsService.reduceAnimation() ? 0 : -(this.animation.gridSquareSize * this.animation.progress);
for (let y = 0; y < this.animation.gridDimension[1]; y++) { for (let y = 0; y < this.animation.gridDimension[1]; y++) {
this.ctx.moveTo(-lineRenderMargin, y * this.animation.gridSquareSize + yOffset + movingOffset); this.ctx.moveTo(
this.ctx.lineTo(this.canvasNativeSize[0] + lineRenderMargin, y * this.animation.gridSquareSize + yOffset + movingOffset); -lineRenderMargin,
y * this.animation.gridSquareSize + yOffset + movingOffset,
);
this.ctx.lineTo(
this.canvasNativeSize[0] + lineRenderMargin,
y * this.animation.gridSquareSize + yOffset + movingOffset,
);
} }
for (let x = 0; x < this.animation.gridDimension[0]; x++) { for (let x = 0; x < this.animation.gridDimension[0]; x++) {
this.ctx.moveTo(x * this.animation.gridSquareSize + xOffset + movingOffset, -lineRenderMargin); this.ctx.moveTo(
this.ctx.lineTo(x * this.animation.gridSquareSize + xOffset + movingOffset, this.canvasNativeSize[1] + lineRenderMargin); x * this.animation.gridSquareSize + xOffset + movingOffset,
-lineRenderMargin,
);
this.ctx.lineTo(
x * this.animation.gridSquareSize + xOffset + movingOffset,
this.canvasNativeSize[1] + lineRenderMargin,
);
} }
this.ctx.stroke() this.ctx.stroke();
} }
ngOnDestroy() { ngOnDestroy() {
if (this.animation.id) { if (this.animation.id) cancelAnimationFrame(this.animation.id);
cancelAnimationFrame(this.animation.id);
}
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
this.intersectionObserver?.disconnect();
} }
} }
+2 -2
View File
@@ -16,11 +16,11 @@
.clock-date { .clock-date {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 500; font-weight: 500;
color: oklch(0.3211 0 0); // #333 color: light-dark(oklch(0.3211 0 0), oklch(0.6789 0 0)); // #333
} }
.clock-time { .clock-time {
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;
color: oklch(0 0 0); // #000 color: light-dark(oklch(0 0 0), oklch(1 0 0)); // #000
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export class Clock implements OnInit, OnDestroy {
ngOnInit() { ngOnInit() {
this.timer = setInterval(() => { this.timer = setInterval(() => {
this.currentTime.set(new Date()); this.currentTime.set(new Date());
}, 1000); }, 500);
} }
ngOnDestroy() { ngOnDestroy() {
+35 -18
View File
@@ -1,24 +1,41 @@
<ng-template #inputTemplate>
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
[attr.placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" tabindex="-1" spellcheck="false"
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"
(focus)="onInputFocusIn()" (focusout)="onInputFocusOut()"></div>
</ng-template>
<div class="dropdown-box"> <div class="dropdown-box">
@if (icon()) { @if (icon()?.trim()?.length ?? 0 > 0) {
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span> <span class="material-symbols-outlined icon-24 prefix-icon" aria-hidden="true">{{ icon() }}</span>
} }
<span class="placeholder">{{ selectedLabel() }}</span>
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span> @if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
@else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
@if (!isOpen()) {
<span class="placeholder" [class.value-empty]="selectedOption() === undefined">{{ selectedLabel() }}</span>
}
<span class="material-symbols-outlined icon-24 chevron" aria-hidden="true" [class.open]="isOpen()">keyboard_arrow_down</span>
</div> </div>
@if (isOpen()) { @if (isOpen()) {
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'"> <div class="dropdown-container" tabindex="-1">
@for (option of normalizedOptions(); track option.value; let i = $index) { <ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
<li @for (option of filteredOptions(); track option.value; let i = $index) {
class="dropdown-item" <li
role="option" #optionItem
[id]="dropdownId + '-option-' + i" class="dropdown-item"
[class.selected]="option.value === value()" role="option"
[class.focused]="i === focusedIndex()" [id]="dropdownID + '-option-' + i"
[attr.aria-selected]="option.value === value()" [class.selected]="option.value === value()"
(click)="selectOption(option); $event.stopPropagation()"> [class.focused]="i === focusedIndex()"
{{ option.label }} [attr.aria-selected]="option.value === value()"
</li> (click)="selectOption(option); $event.stopPropagation()">
} {{ option.label }}
</ul> </li>
}
</ul>
</div>
} }
+130 -43
View File
@@ -2,39 +2,46 @@
@use "sass:color"; @use "sass:color";
@use "styles/library" as library; @use "styles/library" as library;
$dropdown_bg: oklch(1 0 0); // FFFFFF $border_color: oklch(0 0 0 / 20%);
$dropdown_border_color: oklch(0 0 0 / 20%); $border_dark_color: oklch(1 0 0 / 20%);
$dropdown_hover: oklch(0.9702 0 0); // F5F5F5 $hover_bg: oklch(0.9702 0 0); // #F5F5F5
$dropdown_selected: oklch(0.9738 0.0151 54.93); // FFF4ED $hover_dark_bg: oklch(0.25 0 0);
$dropdown_accent: oklch(0.7086 0.1896 39.86); // #FF6E37
$dropdown_height: 40px; $height: 40px;
$dropdown_border_radius: 8px; $border_radius: 8px;
$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1); $animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
$dropdown_animation_duration: 0.2s; $default_animation_duration: 0.2s;
@property --hover-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@property --selected-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host { :host {
display: inline-block; display: inline-block;
position: relative; position: relative;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
padding: 0; margin: 0; padding: 0; margin: 0;
background-color: $dropdown_bg; background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
border-radius: $dropdown_border_radius; border-radius: $border_radius;
box-shadow: 0 0 0 1px $dropdown_border_color inset; box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
outline: none;
cursor: pointer; cursor: pointer;
max-width: 100%; max-width: 100%;
--dropdown-duration: #{$dropdown_animation_duration};
&.reduce-motion { &:has(.dropdown-input.focus-mode:focus-visible) {
--dropdown-duration: 0s; outline: 2px solid var(--selection-outline-color);
} outline-offset: 2px;
&:focus-visible {
box-shadow: 0 0 0 1px $dropdown_border_color inset, 0 0 0 2px white, 0 0 0 4px $dropdown_accent;
} }
} }
@@ -42,7 +49,7 @@ $dropdown_animation_duration: 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 10px; padding: 0 10px;
height: $dropdown_height; height: $height;
box-sizing: border-box; box-sizing: border-box;
gap: 8px; gap: 8px;
@@ -53,19 +60,49 @@ $dropdown_animation_duration: 0.2s;
} }
.placeholder { .placeholder {
text-overflow: ellipsis;
&.value-empty { opacity: 50%; }
}
.dropdown-input {
cursor: text;
&:focus-visible { outline: none; }
&:empty:before {
content: attr(placeholder);
opacity: 50%;
}
&:not(.focus-mode) {
caret-color: transparent;
user-select: none;
}
&.hidden {
position: absolute;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
}
}
.placeholder, .dropdown-input {
flex-grow: 1; flex-grow: 1;
font-size: 18px; font-size: 18px;
line-height: 1.2; // descender(g, y 등)가 잘리지 않도록 행간 확보 height: 24px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
user-select: none; user-select: none;
align-content: center;
} }
.chevron { .chevron {
width: 24px; height: 24px; width: 24px; height: 24px;
flex-shrink: 0; flex-shrink: 0;
transition: transform var(--dropdown-duration) $dropdown_animation_timing; transition: transform var(--animation-duration) $animation_timing;
user-select: none; user-select: none;
&.open { &.open {
@@ -74,36 +111,69 @@ $dropdown_animation_duration: 0.2s;
} }
} }
.dropdown-list { .dropdown-container {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
width: 100%; width: 100%;
padding: 0; // 아이템이 꽉 차도록 패딩 제거 padding: 0;
margin: 0; margin: 0;
list-style: none; background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
background-color: $dropdown_bg; border-radius: $border_radius;
border-radius: $dropdown_border_radius;
z-index: 1000; z-index: 1000;
max-height: 240px; max-height: 200px;
overflow-y: auto; overflow-y: hidden;
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1) box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
animation: slideIn var(--dropdown-duration) $dropdown_animation_timing; animation: slideIn var(--animation-duration) $animation_timing;
.dropdown-list {
padding: 0;
margin: 0;
list-style: none;
width: 100%;
max-height: inherit;
overflow-y: auto;
scrollbar-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%)) transparent;
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
background-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%));
border-radius: 10px;
}
}
// 자식 요소의 배경색이 테두리를 침범하지 못하도록 하는 사용자님의 핵심 설계 복구
&::after { &::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
border-radius: $dropdown_border_radius; border-radius: $border_radius;
box-shadow: 0 0 0 1px $dropdown_border_color inset; box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
pointer-events: none; pointer-events: none;
z-index: 1001; // 테두리를 최상단으로! z-index: 1001;
} }
@keyframes slideIn { @keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); } from {
to { opacity: 1; transform: translateY(0); } transform: translateY(-8px);
pointer-events: none;
opacity: 0;
}
to {
transform: translateY(0);
pointer-events: auto;
opacity: 1;
}
} }
} }
@@ -113,16 +183,33 @@ $dropdown_animation_duration: 0.2s;
height: 40px; height: 40px;
padding: 0 16px; padding: 0 16px;
position: relative; position: relative;
line-height: 1.2; // descender 보호를 위해 1에서 1.2로 수정 align-content: center;
transition: background-color var(--dropdown-duration) $dropdown_animation_timing; transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing, --selected-state calc(var(--animation-duration) / 3) $animation_timing;
font-size: 16px;
background-color: color-mix(
in oklch,
color-mix(
in oklch,
transparent,
light-dark($hover_bg, $hover_dark_bg)
calc(var(--hover-state) * 100%)
),
light-dark(list.nth(library.$brand_palette, 1), list.nth(library.$brand_palette, 12))
calc(var(--selected-state) * 100%)
);
&.focused {
--animation-duration: 0;
}
&:hover, &.focused { &:hover, &.focused {
background-color: $dropdown_hover; --hover-state: 1;
} }
&.selected { &.selected {
background-color: $dropdown_selected; --selected-state: 1;
color: $dropdown_accent; color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
font-weight: 600; font-weight: 600;
} }
} }
+254 -36
View File
@@ -1,6 +1,22 @@
import { Component, input, model, output, signal, HostListener, ElementRef, computed, OnInit import {
} from '@angular/core' Component,
import { CommonModule } from '@angular/common' computed,
effect,
ElementRef,
HostListener,
inject,
input,
model,
OnInit,
output,
PLATFORM_ID,
signal,
untracked,
viewChild,
viewChildren
} from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { TranslationService } from '../../services/translation.service';
export interface DropdownOption { export interface DropdownOption {
label: string; label: string;
@@ -17,14 +33,15 @@ export interface DropdownOption {
role: 'combobox', role: 'combobox',
'aria-haspopup': 'listbox', 'aria-haspopup': 'listbox',
'[attr.aria-expanded]': 'isOpen()', '[attr.aria-expanded]': 'isOpen()',
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null", '[attr.aria-controls]': "isOpen() ? dropdownID + '-listbox' : null",
'[attr.aria-activedescendant]': '[attr.aria-activedescendant]': 'ariaActiveDescendant()',
"isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
'[attr.aria-label]': 'ariaLabel()', '[attr.aria-label]': 'ariaLabel()',
'[class.open]': 'isOpen()', '[class.open]': 'isOpen()',
'[class.reduce-motion]': 'reduceMotion()',
tabindex: '0', tabindex: '0',
'(click)': 'toggle()', '(click)': 'toggle()',
'(focusin)': 'onFocusIn($event)',
'(mousedown)': 'onMouseDown($event)',
'(keydown)': 'onKeyDown($event)',
'(keydown.enter)': 'onEnter($event)', '(keydown.enter)': 'onEnter($event)',
'(keydown.space)': 'onSpaceDown($event)', '(keydown.space)': 'onSpaceDown($event)',
'(keydown.escape)': 'close()', '(keydown.escape)': 'close()',
@@ -33,8 +50,13 @@ export interface DropdownOption {
}, },
}) })
export class Dropdown implements OnInit { export class Dropdown implements OnInit {
translation = inject(TranslationService).translation;
private platformId = inject(PLATFORM_ID);
searchInput = viewChild<ElementRef<HTMLElement>>('searchInput');
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
options = input<Array<DropdownOption | string>>([]); options = input<Array<DropdownOption | string>>([]);
placeholder = input<string>('Select an option'); placeholder = input<string>('');
ariaLabel = input<string | undefined>(undefined); ariaLabel = input<string | undefined>(undefined);
icon = input<string>(); icon = input<string>();
defaultIndex = input<number>(); defaultIndex = input<number>();
@@ -42,18 +64,53 @@ export class Dropdown implements OnInit {
selectionChange = output<any>(); selectionChange = output<any>();
isOpen = signal(false); isOpen = signal(false);
inputFocusMode = signal(false);
focusedIndex = signal(-1); focusedIndex = signal(-1);
searchQuery = signal('');
recreateKey = signal(0);
private static nextId = 0; ariaActiveDescendant = computed(() => {
dropdownId = `dropdown-${Dropdown.nextId++}`; return this.isOpen() && this.focusedIndex() >= 0
? this.dropdownID + '-option-' + this.focusedIndex()
: null;
});
reduceMotion = input<boolean>( private static nextID = 0;
typeof window !== 'undefined' private isLatestMouseDownInsideInput = false;
? window.matchMedia('(prefers-reduced-motion: reduce)').matches dropdownID = `dropdown-${Dropdown.nextID++}`;
: false, private isLatestMouseDownInsideContainer = false;
);
constructor(private el: ElementRef) {} 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() { ngOnInit() {
const opts = this.normalizedOptions(); const opts = this.normalizedOptions();
@@ -65,6 +122,17 @@ export class Dropdown implements OnInit {
} }
} }
private scrollToIndex(index: number) {
if (!isPlatformBrowser(this.platformId)) return;
const items = this.optionItems();
if (items[index]) {
items[index].nativeElement.scrollIntoView({
block: 'nearest',
});
}
}
normalizedOptions = computed(() => { normalizedOptions = computed(() => {
return this.options().map((opt) => { return this.options().map((opt) => {
if (typeof opt === 'string') { if (typeof opt === 'string') {
@@ -74,15 +142,78 @@ export class Dropdown implements OnInit {
}); });
}); });
filteredOptions = computed(() => {
const query = this.searchQuery().toLowerCase().trim().normalize('NFKD');
const opts = this.normalizedOptions();
if (!this.inputFocusMode()) return opts;
if (!query) return opts;
return opts.filter((o) => o.label.toLowerCase().normalize('NFKD').includes(query));
});
toggle() { toggle() {
this.isOpen.update((v) => !v); if (this.isLatestMouseDownInsideInput) {
this.isLatestMouseDownInsideInput = false;
return;
}
if (this.isOpen()) { if (this.isOpen()) {
this.close();
} else {
this.isOpen.set(true);
this.searchQuery.set('');
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value())); this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
setTimeout(() => {
let wasFocusVisible = this.element.nativeElement.matches(':focus-visible');
this.focusSearchInput();
if (wasFocusVisible) this.inputFocusMode.set(true);
}, 0);
} }
} }
onInputFocusIn() {
if (this.isOpen()) this.element.nativeElement.tabIndex = -1;
}
onInputFocusOut() {
if (this.isOpen()) this.element.nativeElement.tabIndex = undefined;
}
onFocusIn(event: FocusEvent) {
if (this.element.nativeElement.contains(event.relatedTarget)) return;
if (this.isOpen()) {
this.focusSearchInput();
this.inputFocusMode.set(true);
}
}
private focusSearchInput() {
const searchInputElement = this.searchInput()!.nativeElement;
searchInputElement.focus();
this.setCursorToEnd(searchInputElement);
}
private checkIfInputFocus(event: Event) {
let triggerInputFocus = this.searchInput()!.nativeElement.contains(
event.target! as HTMLElement,
);
return this.isOpen() && triggerInputFocus;
}
close() { close() {
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
const isDropdownOpen = this.isOpen();
this.isOpen.set(false); this.isOpen.set(false);
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
this.element.nativeElement.tabIndex = undefined;
this.focusedIndex.set(-1); this.focusedIndex.set(-1);
} }
@@ -92,63 +223,150 @@ export class Dropdown implements OnInit {
this.close(); this.close();
} }
onEnter(event: Event) { onConfirm(event: Event) {
event.preventDefault(); event.preventDefault();
if (this.isOpen()) { if (this.isOpen()) {
const currentIndex = this.focusedIndex(); const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) { const options = this.filteredOptions();
this.selectOption(this.normalizedOptions()[currentIndex]); if (currentIndex >= 0 && currentIndex < options.length) {
this.selectOption(options[currentIndex]);
} else if (options.length === 0) {
this.close();
} }
} else { } else {
this.toggle(); this.toggle();
} }
} }
onEnter(event: Event) {
this.onConfirm(event);
}
onSpaceDown(event: Event) { onSpaceDown(event: Event) {
event.preventDefault(); if (this.isOpen() && this.inputFocusMode()) return;
if (this.isOpen()) { this.onConfirm(event);
const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
this.selectOption(this.normalizedOptions()[currentIndex]);
}
} else {
this.toggle();
}
} }
onArrowDown(event: Event) { onArrowDown(event: Event) {
event.preventDefault(); event.preventDefault();
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
if (!this.isOpen()) { if (!this.isOpen()) {
this.toggle(); this.toggle();
return; return;
} }
this.focusedIndex.update((i) => { this.focusedIndex.update((i) => {
const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i === -1) return 0; if (i === -1) return 0;
return (i + 1) % this.normalizedOptions().length; return (i + 1) % options.length;
}); });
} }
onArrowUp(event: Event) { onArrowUp(event: Event) {
event.preventDefault(); event.preventDefault();
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
if (!this.isOpen()) { if (!this.isOpen()) {
this.toggle(); this.toggle();
return; return;
} }
this.focusedIndex.update((i) => { this.focusedIndex.update((i) => {
if (i <= 0) return this.normalizedOptions().length - 1; const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i <= 0) return options.length - 1;
return i - 1; return i - 1;
}); });
} }
@HostListener('document:click', ['$event']) onKeyDown(event: KeyboardEvent) {
onDocumentClick(event: MouseEvent) { const textArrowKeys = ['ArrowLeft', 'ArrowRight'];
if (!this.el.nativeElement.contains(event.target)) {
this.close(); if (!textArrowKeys.includes(event.key)) return;
if (!this.isOpen() || this.inputFocusMode()) return;
this.inputFocusMode.set(true);
}
handleInputMouseDown() {
this.isLatestMouseDownInsideInput = true;
}
onMouseDown(event: MouseEvent) {
this.isLatestMouseDownInsideContainer = this.element.nativeElement.contains(event.target);
if (this.checkIfInputFocus(event)) {
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
} }
} }
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent) {
if (
!this.element.nativeElement.contains(event.target) &&
!this.isLatestMouseDownInsideContainer &&
this.isOpen()
) {
this.close();
}
this.isLatestMouseDownInsideInput = false;
this.isLatestMouseDownInsideContainer = false;
}
handleInputClick(event: MouseEvent) {
event.stopPropagation();
this.isLatestMouseDownInsideInput = false;
this.isLatestMouseDownInsideContainer = false;
}
onSearch(event: Event) {
if (!this.isOpen()) return;
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
const element = event.target as HTMLElement;
const text = element.innerText;
if (text.trim().length <= 0) element.innerHTML = '';
this.focusedIndex.set(0);
this.searchQuery.set(text);
}
preprocessInputContent(event: InputEvent) {
if (event.inputType === 'insertLineBreak' || event.inputType === 'insertParagraph') {
event.preventDefault();
}
if (event.inputType === 'insertFromPaste') {
const originalText = event.dataTransfer!.getData('text/plain');
const modifiedText = originalText.replace(/[\r\n]+/g, '');
event.preventDefault();
// noinspection JSDeprecatedSymbols
document.execCommand('insertText', false, modifiedText);
}
}
selectedOption = computed(() => {
return this.normalizedOptions().find((o) => o.value === this.value());
})
selectedLabel = computed(() => { selectedLabel = computed(() => {
const selected = this.normalizedOptions().find((o) => o.value === this.value()); const selected = this.selectedOption();
return selected ? selected.label : this.placeholder(); return selected ? selected.label : this.placeholder();
}); });
private setCursorToEnd(element: HTMLElement) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}
} }
@@ -0,0 +1,22 @@
<section class="information">
<div class="soundtrack">
<span class="artist">{{ artist() }}</span>
<span class="track-name">{{ trackName() }}</span>
</div>
<div class="other">
<div class="creator">
<span>by</span>
<span>{{ creatorString() }}</span>
</div>
</div>
</section>
<section class="buttons">
<button [aria-label]="previewButtonLabel()">
<div class="material-symbols-outlined icon-40 button-icon">play_arrow</div>
</button>
<button [aria-label]="downloadButtonLabel()">
<div class="material-symbols-outlined icon-40 button-icon">download</div>
</button>
</section>
@@ -0,0 +1,162 @@
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
$default_animation_duration: 0.2s;
@property --hover-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host {
width: 100%;
max-width: 400px;
height: 160px;
display: flex;
flex-direction: row;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
justify-content: space-between;
border-radius: 8px;
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
// overflow: hidden;
&:focus-visible {
outline-offset: 0;
outline: 2px solid var(--selection-outline-color);
}
section.information {
width: calc(100% - 64px);
padding: 12px;
box-sizing: border-box;
flex-shrink: 0;
z-index: 1;
background-color: light-dark(white, black);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
// outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 1px solid;
.soundtrack {
display: flex;
flex-direction: column;
gap: 4px;
.artist {
display: inline-block;
font-size: 14px;
height: 20px;
align-content: center;
font-weight: 700;
white-space: nowrap;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
.track-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
align-self: stretch;
overflow: hidden;
text-overflow: ellipsis;
word-wrap: break-word;
font-size: 18px;
font-weight: 900;
line-height: 22px;
height: 44px;
}
}
.other {
display: flex;
width: 312px;
height: 68px;
padding: 3px 0;
flex-direction: column;
box-sizing: border-box;
justify-content: space-between;
align-items: flex-start;
.creator {
display: flex;
flex-direction: row;
font-size: 14px;
height: 20px;
gap: 3px;
span {
align-content: center;
&:nth-child(2) {
font-weight: 700;
}
}
}
}
}
section.buttons {
display: flex;
flex-direction: column;
align-self: stretch;
flex-shrink: 0;
flex-grow: 1;
margin-left: 0;
button {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
z-index: 10;
width: 100%;
height: 50%;
border: none;
transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing;
background-color: color-mix(
in oklch,
light-dark(oklch(1 0 0), oklch(0 0 0)),
light-dark(oklch(0.9702 0 0), oklch(0.25 0 0))
calc(var(--hover-state) * 100%)
);
padding: 0;
&:hover {
--hover-state: 1;
}
&:not(:focus-visible) {
box-shadow: -1px 0 0 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
}
&:focus-visible {
z-index: 11;
outline-offset: 0;
}
div.button-icon {
width: 40px;
height: 40px;
margin-right: 12px;
user-select: none;
}
&:first-child {
border-top-right-radius: 8px;
}
&:last-child {
border-bottom-right-radius: 8px;
}
}
}
}
@@ -0,0 +1,35 @@
import { Component, computed, inject, input } from '@angular/core';
import { TranslationService } from '../../services/translation.service';
@Component({
selector: 'level-card',
imports: [],
templateUrl: './level-card.html',
styleUrl: './level-card.scss',
host: {
role: 'article',
},
})
export class LevelCard {
private translationSvc = inject(TranslationService);
protected translation = this.translationSvc.translation;
artist = input.required<string>();
trackName = input.required<string>();
creator = input<string[]>([]);
creatorString = computed(() => this.creator().join(' & '));
previewButtonLabel = computed(() =>
this.translationSvc.interpolate(this.translation.levelCard.preview, {
artist: this.artist(),
trackName: this.trackName(),
}),
);
downloadButtonLabel = computed(() =>
this.translationSvc.interpolate(this.translation.levelCard.download, {
creator: this.creatorString()
}),
);
}
+4 -5
View File
@@ -1,19 +1,18 @@
<div class="popup-backdrop" [class.center]="isCenter" (click)="onClose()"> <div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()">
<div class="popup-container" <div #popupContainer class="popup-container"
[class]="'variant-' + variant()" [class]="'variant-' + variant()"
[class.custom-pos]="!isCenter" [class.custom-pos]="!isCenter"
[style.top]="customStyle.top" [style.top]="customStyle.top"
[style.left]="customStyle.left" [style.left]="customStyle.left"
[style.right]="customStyle.right" [style.right]="customStyle.right"
[style.bottom]="customStyle.bottom" [style.bottom]="customStyle.bottom">
(click)="$event.stopPropagation()">
<header class="popup-header"> <header class="popup-header">
<div class="header-left"> <div class="header-left">
<ng-content select="[header-left]"></ng-content> <ng-content select="[header-left]"></ng-content>
</div> </div>
<div class="header-right"> <div class="header-right">
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()">close</button> <button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()" [aria-label]="closeButtonLabel()">close</button>
</div> </div>
</header> </header>
+40 -11
View File
@@ -1,5 +1,15 @@
$animation_timing: cubic-bezier(0.16, 1, 0.3, 1);
$default_animation_duration: 0.2s;
@property --close-hover-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host { :host {
display: block; display: block;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
} }
.popup-backdrop { .popup-backdrop {
@@ -24,13 +34,13 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible; overflow: visible;
animation: popup-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1); animation: popup-enter var(--animation-duration) $animation_timing;
border: oklch(0 0 0 / 20%) 2px solid; outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
gap: 15px; gap: 12px;
&.variant-glass { &.variant-glass {
background-color: oklch(1 0 0 / 60%); background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
} }
@@ -42,15 +52,22 @@
&.custom-pos { &.custom-pos {
position: absolute; position: absolute;
} }
&:focus-visible {
outline-offset: 0;
outline: 2px solid var(--selection-outline-color);
}
} }
@keyframes popup-enter { @keyframes popup-enter {
from { from {
transform: scale(0.95) translateY(10px); transform: scale(0.95) translateY(8px);
pointer-events: none;
opacity: 0; opacity: 0;
} }
to { to {
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
pointer-events: auto;
opacity: 1; opacity: 1;
} }
} }
@@ -61,7 +78,7 @@
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
height: 40px; height: 40px;
border-bottom: 1px solid oklch(0 0 0 / 5%); box-shadow: 0 1px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
.header-left { .header-left {
display: flex; display: flex;
@@ -71,17 +88,29 @@
.header-right { .header-right {
.close-btn { .close-btn {
background: none; --close-hover-state: 0;
border: none; border: none;
padding: 4px; padding: 4px;
cursor: pointer; cursor: pointer;
border-radius: 50%; border-radius: 50%;
color: oklch(0.4 0 0);
transition: background-color 0.2s, color 0.2s; background: color-mix(
in oklch,
transparent,
light-dark(oklch(0 0 0 / 5%), oklch(1 0 0 / 5%))
calc(var(--close-hover-state) * 100%)
);
color: color-mix(
in oklch,
light-dark(oklch(0.4 0 0), oklch(0.6 0 0)),
light-dark(oklch(0 0 0), oklch(1 0 0))
calc(var(--close-hover-state) * 100%)
);
transition: --close-hover-state 0.2s;
&:hover { &:hover {
background-color: oklch(0 0 0 / 5%); --close-hover-state: 1;
color: oklch(0 0 0);
} }
} }
} }
+115 -2
View File
@@ -1,4 +1,15 @@
import { Component, input, output } from '@angular/core'; import {
Component,
input,
output,
HostListener,
ElementRef,
inject,
viewChild,
OnInit,
OnDestroy,
} from '@angular/core';
import { TranslationService } from '../../services/translation.service';
export type PopupPosition = { export type PopupPosition = {
top?: string; top?: string;
@@ -14,8 +25,17 @@ export type PopupVariant = 'glass' | 'solid';
standalone: true, standalone: true,
templateUrl: './popup.html', templateUrl: './popup.html',
styleUrl: './popup.scss', styleUrl: './popup.scss',
host: {
role: 'dialog',
'aria-modal': 'true',
tabindex: '-1',
},
}) })
export class PopupComponent { export class PopupComponent implements OnInit, OnDestroy {
private translation = inject(TranslationService).translation;
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
private previouslyFocusedElement: HTMLElement | null = null;
/** /**
* 팝업의 위치 (기본값은 'center') * 팝업의 위치 (기본값은 'center')
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다. * 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
@@ -27,11 +47,104 @@ export class PopupComponent {
*/ */
variant = input<PopupVariant>('glass'); variant = input<PopupVariant>('glass');
/**
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
*/
closeButtonLabel = input<string>(this.translation.common.close);
/** /**
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼) * 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
*/ */
closeRequest = output<void>(); closeRequest = output<void>();
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() { onClose() {
this.closeRequest.emit(); this.closeRequest.emit();
} }
@@ -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 {}
+45 -31
View File
@@ -2,35 +2,53 @@
@use "sass:list"; @use "sass:list";
@use "styles/library" as library; @use "styles/library" as library;
$switch_on_bg: oklch(0.7086 0.1896 39.86); // #FF6E37 $off_bg: oklch(0.683 0 0); // #999999
$switch_off_bg: oklch(0.683 0 0); // #999999 $off_dark_bg: oklch(0 0 0);
$switch_dimension: (57px, 30px); $dimension: (57px, 30px);
$switch_slider_dimension: (24px, 24px); $knob_dimension: (24px, 24px);
$switch_padding: (3px, 3px); $internal_padding: (3px, 3px);
$switch_border_radius: 15px;
$switch_border_color: oklch(0 0 0 / 20%);
$switch_animation_timing: cubic-bezier(0.4, 0, 0.2, 1); $border_radius: 15px;
$switch_animation_duration: 0.2s; $border_color: oklch(0 0 0 / 20%);
$border_dark_color: oklch(1 0 0 / 40%);
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
$default_animation_duration: 0.2s;
@property --animation-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host { :host {
display: inline-block; display: inline-block;
width: list.nth($switch_dimension, 1); height: list.nth($switch_dimension, 2); width: list.nth($dimension, 1); height: list.nth($dimension, 2);
border-radius: $switch_border_radius; border-radius: $border_radius;
background-color: $switch_off_bg;
background-color: color-mix(
in oklch,
light-dark($off_bg, $off_dark_bg),
light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6))
calc(var(--animation-state) * 100%)
);
box-shadow: 0 0 0 1px color-mix(
in oklch,
light-dark($border_color, $border_dark_color),
light-dark($border_color, list.nth(library.$brand_palette, 9))
calc(var(--animation-state) * 100%)
) inset;
--animation-state: 0;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
// transition: background-color var(--animation-duration) $animation_timing, box-shadow var(--animation-duration) $animation_timing;
transition: --animation-state var(--animation-duration) $animation_timing;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
box-shadow: 0 0 0 1px $switch_border_color inset;
outline: none;
--switch-duration: #{$switch_animation_duration};
transition: background-color var(--switch-duration) $switch_animation_timing;
&.reduce-motion {
--switch-duration: 0s;
}
&:hover, &:focus-within { &:hover, &:focus-within {
.switch-knob { .switch-knob {
@@ -38,17 +56,13 @@ $switch_animation_duration: 0.2s;
} }
} }
&:focus-visible {
box-shadow: 0 0 0 1px $switch_border_color inset, 0 0 0 2px white, 0 0 0 4px $switch_on_bg;
}
&.active { &.active {
background-color: $switch_on_bg; --animation-state: 1;
.switch-knob { .switch-knob {
transform: translate( transform: translate(
list.nth($switch_dimension, 1) - list.nth($switch_slider_dimension, 1) - list.nth($switch_padding, 1) * 2, list.nth($dimension, 1) - list.nth($knob_dimension, 1) - list.nth($internal_padding, 1) * 2,
list.nth($switch_dimension, 2) - list.nth($switch_slider_dimension, 2) - list.nth($switch_padding, 2) * 2 list.nth($dimension, 2) - list.nth($knob_dimension, 2) - list.nth($internal_padding, 2) * 2
); );
} }
} }
@@ -56,10 +70,10 @@ $switch_animation_duration: 0.2s;
.switch-knob { .switch-knob {
position: absolute; position: absolute;
width: list.nth($switch_slider_dimension, 1); height: list.nth($switch_slider_dimension, 2); width: list.nth($knob_dimension, 1); height: list.nth($knob_dimension, 2);
left: list.nth($switch_padding, 1); top: list.nth($switch_padding, 2); left: list.nth($internal_padding, 1); top: list.nth($internal_padding, 2);
background-color: white; background-color: white;
border-radius: 50%; border-radius: 50%;
user-select: none; user-select: none;
transition: transform var(--switch-duration) $switch_animation_timing; transition: transform var(--animation-duration) $animation_timing;
} }
+1 -8
View File
@@ -1,4 +1,4 @@
import { Component, input, model, output } from '@angular/core'; import { Component, model, output } from '@angular/core';
@Component({ @Component({
selector: 'switch', selector: 'switch',
@@ -10,7 +10,6 @@ import { Component, input, model, output } from '@angular/core';
tabindex: '0', tabindex: '0',
'[attr.aria-checked]': 'isActive()', '[attr.aria-checked]': 'isActive()',
'[class.active]': 'isActive()', '[class.active]': 'isActive()',
'[class.reduce-motion]': 'reduceMotion()',
'(click)': 'onClick()', '(click)': 'onClick()',
'(keydown.enter)': 'onClick()', '(keydown.enter)': 'onClick()',
'(keydown.space)': 'onSpaceDown($event)', '(keydown.space)': 'onSpaceDown($event)',
@@ -20,12 +19,6 @@ export class Switch {
isActive = model<boolean>(false); isActive = model<boolean>(false);
toggle = output<boolean>(); toggle = output<boolean>();
reduceMotion = input<boolean>(
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
);
onClick(): void { onClick(): void {
this.isActive.update((value) => !value); this.isActive.update((value) => !value);
this.onToggle(); this.onToggle();
+27 -15
View File
@@ -1,19 +1,31 @@
<div class="menu-left"> <div #menuLeft class="menu-left">
<div class="brand" [routerLink]="['/']"> <a class="brand" [routerLink]="['/']">
<img ngSrc="/logo_inverted.svg" alt="logo" height="20" width="59.52" /> <img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
<span><strong>ADOFAI Editor Forum</strong></span> <span #brandText [class.hidden]="expandStage() < 2">
<strong>ADOFAI Editor Forum</strong>
</span>
</a>
<div #leftMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
</div>
</div>
<div #menuRight class="menu-right">
<div #rightMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
</div> </div>
<span class="dummy-link">Levels</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">
<span class="dummy-link">Community</span> menu
<span class="dummy-link">Leaderboard</span> </button>
<span class="dummy-link">About</span>
</div>
<div class="menu-right"> <button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
<span class="nav-link" [routerLink]="['/known-issues']">Known Issues</span> settings
<span class="dummy-link"><strong>Login</strong></span> </button>
<span class="dummy-link">Signup</span>
<span class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()">settings</span>
</div> </div>
+92 -25
View File
@@ -1,16 +1,19 @@
@use "sass:map";
:host { :host {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: start; justify-content: space-between;
gap: 30px; gap: 0;
width: fit-content; width: fit-content;
min-width: 100%; min-width: 100%;
height: 64px;
box-sizing: border-box; box-sizing: border-box;
border-bottom: oklch(0 0 0 / 20%) 2px solid; box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
background-color: oklch(1 0 0 / 60%); background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
@@ -24,17 +27,40 @@
align-items: center; align-items: center;
gap: 48px; 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; font-size: 18px;
line-height: 40px; height: 40px;
align-content: center;
vertical-align: middle; vertical-align: middle;
white-space: nowrap; white-space: nowrap;
font-family: inherit;
color: inherit;
cursor: pointer;
display: flex;
align-items: center;
border-radius: 4px;
&.nav-link { &.nav-link {
font-weight: 700; font-weight: 700;
cursor: pointer; color: oklch(0.6 0.15 250);
color: oklch(0.5 0.15 250); // Primary color highlight (blue-ish)
transition: opacity 0.3s; transition: opacity 0.3s;
&:hover { &:hover {
@@ -45,10 +71,49 @@
&.dummy-link { &.dummy-link {
cursor: not-allowed; 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 { .menu-left {
position: absolute;
left: 0;
justify-content: start; justify-content: start;
padding: 12px; padding: 12px;
@@ -59,6 +124,7 @@
justify-content: start; justify-content: start;
gap: 0; gap: 0;
cursor: pointer; cursor: pointer;
margin-left: 10px;
&:hover { &:hover {
opacity: 0.7; opacity: 0.7;
@@ -67,30 +133,31 @@
img { img {
display: inline-block; display: inline-block;
height: 20px; height: 20px;
padding: 10px; padding: 10px 0;
}
strong {
font-weight: 900 !important;
} }
span { span {
/*text-box-trim: trim-both; display: inline-block;
text-box-edge: cap alphabetic;*/ padding-left: 10px;
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
} }
} }
} }
.menu-right { .menu-right {
flex-grow: 1; position: absolute;
right: 0;
justify-content: flex-end; justify-content: flex-end;
padding: 12px 20px; padding: 12px 20px 12px 12px;
.settings {
position: relative;
z-index: 10;
cursor: pointer;
transition: transform 0.3s ease;
&:hover {
transform: rotate(30deg);
}
}
} }
} }
+151 -6
View File
@@ -1,20 +1,165 @@
import { Component, inject } from '@angular/core'; import {
afterNextRender,
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
NgZone,
OnDestroy,
signal,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { NgOptimizedImage } from '@angular/common';
import { PopupService } from '../../services/popup.service'; import { PopupService } from '../../services/popup.service';
import { SettingsPopup } from '../settings/settings'; import { SettingsPopup } from '../settings/settings';
import { NavigationMenu } from './navigation-menu';
import { TranslationService } from '../../services/translation.service';
import { SettingsService } from '../../services/settings.service';
@Component({ @Component({
selector: 'header-menu', selector: 'header-menu',
imports: [RouterLink, NgOptimizedImage], imports: [RouterLink],
templateUrl: './header-menu.html', templateUrl: './header-menu.html',
styleUrl: './header-menu.scss', styleUrl: './header-menu.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.folded-menu]': 'expandStage() < 1',
},
}) })
export class HeaderMenu { export class HeaderMenu implements OnDestroy {
private popupService = inject(PopupService); private popupService = inject(PopupService);
protected translation = inject(TranslationService).translation;
protected settings = inject(SettingsService);
private static readonly LOGICAL_GAP = 24;
private resizeObserver?: ResizeObserver;
private brandTextObserver?: ResizeObserver;
private menuItemsObserver?: ResizeObserver;
private menuWidth: number = 99999;
private menuLeft = viewChild<ElementRef<HTMLElement>>('menuLeft');
private leftMenuItems = viewChild<ElementRef<HTMLElement>>('leftMenuItems');
private menuLeftWidth: number = 0;
private leftMenuItemsWidth: number = 0;
private menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
private rightMenuItems = viewChild<ElementRef<HTMLElement>>('rightMenuItems');
private menuRightWidth: number = 0;
private rightMenuItemsWidth: number = 0;
private brandText = viewChild<ElementRef<HTMLElement>>('brandText');
private brandTextWidth: number = 0;
expandStage = signal(2);
constructor(
private element: ElementRef,
private ngZone: NgZone,
) {
effect(() => {
if (this.expandStage() > 0) {
this.popupService.close(NavigationMenu);
}
});
afterNextRender(() => {
this.ngZone.runOutsideAngular(() => {
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
if (width > 0)
switch (entry.target) {
case element.nativeElement:
this.menuWidth = width;
break;
case this.menuLeft()!.nativeElement:
this.menuLeftWidth = width;
break;
case this.menuRight()!.nativeElement:
this.menuRightWidth = width;
break;
}
}
this.updateExpandStatus();
});
this.menuItemsObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
if (width > 0)
switch (entry.target) {
case this.leftMenuItems()!.nativeElement:
this.leftMenuItemsWidth = width;
break;
case this.rightMenuItems()!.nativeElement:
this.rightMenuItemsWidth = width;
break;
}
}
this.updateExpandStatus();
});
this.resizeObserver.observe(element.nativeElement);
this.resizeObserver.observe(this.menuLeft()!.nativeElement);
this.resizeObserver.observe(this.menuRight()!.nativeElement);
this.menuItemsObserver.observe(this.leftMenuItems()!.nativeElement);
this.menuItemsObserver.observe(this.rightMenuItems()!.nativeElement);
this.brandTextObserver = new ResizeObserver((entries) => {
const width = entries[0].borderBoxSize[0].inlineSize;
if (width > 0) this.brandTextWidth = width;
this.updateExpandStatus();
});
this.brandTextObserver.observe(this.brandText()!.nativeElement);
});
});
}
ngOnDestroy() {
this.resizeObserver?.disconnect();
this.menuItemsObserver?.disconnect();
this.brandTextObserver?.disconnect();
}
private updateExpandStatus() {
let menuContentWidth = this.menuLeftWidth + this.menuRightWidth;
let enoughSpace = this.menuWidth > menuContentWidth + HeaderMenu.LOGICAL_GAP;
let initialExpandStage = this.expandStage();
if (enoughSpace) {
let extraSpaceToExpand = Number.MAX_VALUE;
switch (initialExpandStage) {
case 1:
extraSpaceToExpand = this.brandTextWidth;
break;
case 0:
extraSpaceToExpand = this.leftMenuItemsWidth + this.rightMenuItemsWidth + 48 - 40;
break;
}
if (
// Add a gap of 2px to prevent flickering
this.menuWidth >= menuContentWidth + HeaderMenu.LOGICAL_GAP + extraSpaceToExpand + 2
) {
this.expandStage.set(initialExpandStage + 1);
}
} else if (initialExpandStage > 0) this.expandStage.set(initialExpandStage - 1);
}
onHamburgerClick(): void {
this.popupService.open(NavigationMenu, {
position: { top: '84px', right: '20px' },
});
}
onSettingsClick() { onSettingsClick() {
console.log('HeaderMenu: Settings clicked'); this.popupService.open(SettingsPopup, { position: { top: '84px', right: '20px' } });
this.popupService.open(SettingsPopup, { position: { top: '82px', right: '18px' } });
} }
} }
@@ -0,0 +1,23 @@
<app-popup [position]="position()" (closeRequest)="close()">
<div header-left class="menu-header-title">
<span class="material-symbols-outlined">menu</span>
<span>{{ translation.menu.menu }}</span>
</div>
<div class="menu-content">
<div class="menu-section">
<a class="menu-item nav-link" [routerLink]="['/levels']" (click)="close()">{{ translation.menu.levels }}</a>
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
</div>
<hr class="menu-divider" />
<div class="menu-section">
<a class="menu-item nav-link" [routerLink]="['/known-issues']" (click)="close()">{{ translation.menu.knownIssues }}</a>
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
</div>
</div>
</app-popup>
@@ -0,0 +1,73 @@
:host {
display: block;
user-select: none;
}
.menu-header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
.material-symbols-outlined {
font-size: 24px;
}
}
.menu-content {
display: flex;
flex-direction: column;
min-width: 276px;
padding-bottom: 12px;
gap: 12px;
}
.menu-section {
display: flex;
flex-direction: column;
}
.menu-divider {
height: 1px;
border: none;
background-color: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
margin: 0 6px;
}
.menu-item {
display: flex;
align-items: center;
align-content: center;
height: 40px;
padding-left: 12px;
text-decoration: none;
color: inherit;
font-size: 18px;
cursor: pointer;
vertical-align: middle;
transition: background-color 0.2s;
border-radius: 4px;
.material-symbols-outlined {
font-size: 24px;
color: light-dark(oklch(0.4 0 0), oklch(0.8 0 0));
}
strong {
font-weight: 700;
}
&.nav-link {
font-weight: 700;
color: oklch(0.6 0.15 250);
transition: opacity 0.3s;
&:hover {
opacity: 0.7;
}
}
&.dummy-link {
cursor: not-allowed;
}
}
@@ -0,0 +1,26 @@
import { Component, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
import { PopupService } from '../../services/popup.service';
import { TranslationService } from '../../services/translation.service';
@Component({
selector: 'navigation-menu',
standalone: true,
imports: [PopupComponent, RouterLink],
templateUrl: './navigation-menu.html',
styleUrl: './navigation-menu.scss',
})
export class NavigationMenu {
private popupService = inject(PopupService);
protected translation = inject(TranslationService).translation;
/**
* 팝업의 위치
*/
position = input<PopupPosition | 'center'>('center');
close() {
this.popupService.close(NavigationMenu);
}
}
+2
View File
@@ -0,0 +1,2 @@
<label class="category-header">{{ headerLabel() }}</label>
<ng-content></ng-content>
+17
View File
@@ -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
}
+17
View File
@@ -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>
}
+55
View File
@@ -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;
}
}
-38
View File
@@ -1,38 +0,0 @@
<app-popup (closeRequest)="close()" [position]="position()">
<div header-left class="profile-section">
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
<span>{{ userName }}</span>
</div>
<div class="settings-content">
<section class="settings-category">
<label class="category-header">Language</label>
<div class="settings-container">
<dropdown [options]="languages"
placeholder="Choose Language"
[defaultIndex]="0"
icon="language"></dropdown>
</div>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span>
<span>Romanisation</span>
</div>
<switch></switch>
</div>
</section>
<section class="settings-category">
<label class="category-header">Appearance</label>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span>
<span>Dark Mode</span>
</div>
<switch></switch>
</div>
</section>
</div>
</app-popup>
-79
View File
@@ -1,79 +0,0 @@
:host {
display: block;
}
.profile-section {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
.avatar {
border-radius: 50%;
border: 1px solid oklch(0 0 0 / 20%);
object-fit: cover;
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 8px;
section {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
justify-content: flex-start;
margin: 0; padding: 0;
border: none;
label {
display: block;
font-weight: 500;
font-size: 14px;
color: oklch(0.6830 0 0); // #999999
}
.settings-container {
width: 100%;
height: 40px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.settings-name {
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
gap: 8px;
span {
display: inline-block;
font-size: 18px;
&.settings-icon {
display: inline-block;
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
}
}
}
}
dropdown {
width: 324px;
}
}
}
+42
View File
@@ -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>
+29
View File
@@ -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;
}
+72 -5
View File
@@ -1,19 +1,70 @@
import { Component, inject, input } from '@angular/core'; import { Component, inject, input, output } from '@angular/core';
import { PopupComponent, PopupPosition } from '../../components/popup/popup'; import { PopupComponent, PopupPosition } from '../../components/popup/popup';
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown'; import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
import { Switch } from '../../components/switch/switch'; import { Switch } from '../../components/switch/switch';
import { NgOptimizedImage } from '@angular/common'; import { NgOptimizedImage } from '@angular/common';
import { PopupService } from '../../services/popup.service'; import { PopupService } from '../../services/popup.service';
import { SettingsService, Language } from '../../services/settings.service';
import { TranslationService } from '../../services/translation.service';
export enum SettingsType {
Switch = 'switch', Dropdown = 'dropdown',
}
@Component({
selector: 'settings-category',
standalone: true,
templateUrl: './category.html',
styleUrl: './category.scss',
})
export class SettingsCategory {
headerLabel = input<string>();
}
@Component({
selector: 'settings-container',
standalone: true,
templateUrl: './container.html',
styleUrl: './container.scss',
imports: [Switch, Dropdown],
host: {
// For IDE Compatibility
'[class.settings-container]': 'true',
},
})
export class SettingsContainer {
settingsType = input.required<SettingsType>();
protected readonly SettingsType = SettingsType;
labelIcon = input<string>();
labelText = input.required<string>();
value = input.required<any>();
onChange = output<any>();
placeholder = input<string>();
options = input<any>();
private static nextID = 0;
labelID = `settings-${SettingsContainer.nextID++}-label`;
}
@Component({ @Component({
selector: 'settings-popup', selector: 'settings-popup',
standalone: true, standalone: true,
imports: [PopupComponent, Dropdown, Switch, NgOptimizedImage], imports: [
templateUrl: './popup.html', PopupComponent,
styleUrl: './popup.scss', NgOptimizedImage,
SettingsCategory,
SettingsContainer,
],
templateUrl: './settings.html',
styleUrl: './settings.scss',
}) })
export class SettingsPopup { export class SettingsPopup {
private popupService = inject(PopupService); private popupService = inject(PopupService);
protected settingsService = inject(SettingsService);
protected translation = inject(TranslationService).translation;
/** /**
* 팝업의 위치 (기본값은 'center') * 팝업의 위치 (기본값은 'center')
@@ -26,7 +77,21 @@ export class SettingsPopup {
{ label: '日本語', value: 'JP' }, { label: '日本語', value: 'JP' },
]; ];
userName: string = "Guest"; onLanguageChange(lang: Language) {
this.settingsService.setLanguage(lang);
}
onDarkModeChange(isDark: boolean) {
this.settingsService.setDarkMode(isDark);
}
onRomanisationChange(value: boolean) {
this.settingsService.setRomanisation(value);
}
onReduceAnimationChange(value: boolean) {
this.settingsService.setReduceAnimation(value);
}
/** /**
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다. * 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
@@ -34,4 +99,6 @@ export class SettingsPopup {
close() { close() {
this.popupService.close(SettingsPopup); this.popupService.close(SettingsPopup);
} }
protected readonly SettingsType = SettingsType;
} }
+33
View File
@@ -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;
+33
View File
@@ -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}}のレベルをダウンロード',
},
};
+33
View File
@@ -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}}의 레벨 다운로드',
},
};
@@ -1,3 +1,11 @@
:host > .container {
display: flex;
width: 600px;
flex-direction: column;
gap: 40px;
}
section { section {
p { p {
line-height: 1.4; line-height: 1.4;
@@ -12,6 +20,8 @@ section {
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
width: fit-content;
&.reported { &.reported {
background-color: oklch(0.786 0.1379 18.69); background-color: oklch(0.786 0.1379 18.69);
} }
+40
View File
@@ -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>
+17
View File
@@ -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;
}
+15
View File
@@ -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 {
}
+7 -9
View File
@@ -1,21 +1,19 @@
<div class="container"> <brand-accent></brand-accent>
<brand-accent></brand-accent>
<h1>AEF Component Test</h1> <div class="container">
<h1>Component Test</h1>
<section id="switch-test" class="feature-block"> <section id="switch-test" class="feature-block">
<h2>Switch Component</h2> <h2>Switch Component</h2>
<div style="display: flex; flex-direction: column; gap: 40px; align-items: flex-start;"> <div class="test-container">
<div class="test-container"> <switch [(isActive)]="isToggled" (toggle)="handleToggle($event)"></switch>
<switch [(isActive)]="isToggled" (toggle)="handleToggle($event)"></switch> <p>Switch is: {{ isToggled() ? 'ON' : 'OFF' }}</p>
<p>Switch is: {{ isToggled() ? 'ON' : 'OFF' }}</p>
</div>
</div> </div>
</section> </section>
<section id="dropdown-test" class="feature-block"> <section id="dropdown-test" class="feature-block">
<h2>Dropdown Component</h2> <h2>Dropdown Component</h2>
<div style="display: flex; flex-direction: column; gap: 40px; align-items: flex-start;"> <div class="test-group">
<div class="test-container"> <div class="test-container">
<h3>Object Options</h3> <h3>Object Options</h3>
<dropdown <dropdown
+41 -5
View File
@@ -1,21 +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 { section#dropdown-test {
dropdown { dropdown {
width: 324px; width: 100%;
max-width: 324px;
} }
} }
div.test-group {
display: flex;
flex-direction: column;
gap: 36px;
padding-top: 24px;
}
div.test-container { div.test-container {
max-width: 100%; max-width: 100%;
h3 {
margin-bottom: 6px;
}
p {
margin-top: 6px;
}
} }
brand-accent { brand-accent {
position: absolute; position: absolute;
width: 100%; top: -64px;
height: 100%;
top: 0;
left: 0; left: 0;
width: 100%;
min-width: 100dvw;
height: calc(100% + 64px);
min-height: 100dvh;
z-index: -999999; z-index: -999999;
} }
+37
View File
@@ -17,6 +17,43 @@ export class MainPage {
{ label: 'English', value: 'EN' }, { label: 'English', value: 'EN' },
{ label: '한국어', value: 'KR' }, { label: '한국어', value: 'KR' },
{ label: '日本語', value: 'JP' }, { label: '日本語', value: 'JP' },
{ label: 'Deutsch', value: 'DE' },
{ label: 'Italiano', value: 'IT' },
{ label: '中文', value: 'CN' },
{ label: 'Français', value: 'FR' },
{ label: 'Español', value: 'ES' },
{ label: 'Português', value: 'PT' },
{ label: 'Русский', value: 'RU' },
{ label: 'العربية', value: 'AR' },
{ label: 'हिन्दी', value: 'HI' },
{ label: 'বাংলা', value: 'BN' },
{ label: 'Tiếng Việt', value: 'VI' },
{ label: 'Türkçe', value: 'TR' },
{ label: 'Polski', value: 'PL' },
{ label: 'Українська', value: 'UK' },
{ label: 'Nederlands', value: 'NL' },
{ label: 'Ελληνικά', value: 'EL' },
{ label: 'Magyar', value: 'HU' },
{ label: 'Čeština', value: 'CS' },
{ label: 'Română', value: 'RO' },
{ label: 'Svenska', value: 'SV' },
{ label: 'Dansk', value: 'DA' },
{ label: 'Suomi', value: 'FI' },
{ label: 'Norsk', value: 'NO' },
{ label: 'ไทย', value: 'TH' },
{ label: 'Bahasa Indonesia', value: 'ID' },
{ label: 'Bahasa Melayu', value: 'MS' },
{ label: 'Filipino', value: 'TL' },
{ label: 'עברית', value: 'HE' },
{ label: 'فارسی', value: 'FA' },
{ label: 'اردو', value: 'UR' },
{ label: 'தமிழ்', value: 'TA' },
{ label: 'తెలుగు', value: 'TE' },
{ label: 'ಕನ್ನಡ', value: 'KN' },
{ label: 'മലയാളം', value: 'ML' },
{ label: 'मराठी', value: 'MR' },
{ label: 'ગુજરાતી', value: 'GU' },
{ label: 'ਪੰਜਾਬੀ', value: 'PA' },
]; ];
selectedDropdownValue = signal<any>(null); selectedDropdownValue = signal<any>(null);
+118
View File
@@ -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
}
}
}
+42
View File
@@ -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
View File
@@ -57,8 +57,7 @@ if (isMainModule(import.meta.url) || process.env['pm_id']) {
if (error) { if (error) {
throw error; throw error;
} }
console.log(`Node Express server listening on http://${host}:${port}`);
console.log(`Node Express server listening...`);
}); });
} }
+61 -9
View File
@@ -1,13 +1,46 @@
@use "sass:list";
@use "styles/library" as library;
@import url('https://cdn.jsdelivr.net/npm/nanumsquareneo@0.0.5/nanumsquareneo.css'); @import url('https://cdn.jsdelivr.net/npm/nanumsquareneo@0.0.5/nanumsquareneo.css');
@import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css'); @import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css');
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css"); @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css");
:root {
--motion-duration-multiplier: 1;
}
:root.reduce-motion {
--motion-duration-multiplier: 0 !important;
}
html {
color-scheme: light;
--if-dark: initial;
&.dark-theme {
--if-dark: ;
color-scheme: dark;
color: white;
& > body {
background-color: oklch(0.1776 0 0);
}
section.feature-block {
background-color: oklch(0 0 0 / 60%);
outline: 2px solid oklch(1 0 0 / 20%);
}
}
}
body { body {
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif; font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif;
font-weight: 500; font-weight: 500;
margin: 0; margin: 0;
overscroll-behavior-x: none; overscroll-behavior-x: none;
background-color: white; background-color: white;
min-height: 100dvh;
} }
.icon-24 { .icon-24 {
@@ -26,21 +59,28 @@ strong {
font-weight: 900; font-weight: 900;
} }
h1, h2, h3 {
margin: 0;
}
h1 { h1 {
margin-bottom: 40px; font-size: 48px;
text-align: center; text-align: center;
} }
h2 { h2 {
margin-top: 0; font-size: 24px;
margin-bottom: 20px; color: oklch(0.6830 0 0); // #999999
font-size: 1.2rem; }
color: oklch(0.5103 0 0); // #666
h3 {
font-size: 18px;
} }
p { p {
margin-top: 15px; margin-top: 0; margin-bottom: 0;
font-weight: 500; font-weight: 500;
font-size: 16px;
} }
.container { .container {
@@ -53,7 +93,7 @@ p {
position: fixed; position: fixed;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: 10000; /* 최상위 레이어 */ z-index: 10000;
} }
#global-popup-root > * { #global-popup-root > * {
@@ -62,9 +102,12 @@ p {
} }
section.feature-block { section.feature-block {
margin-bottom: 40px; display: flex;
flex-direction: column;
gap: 12px;
padding: 20px; padding: 20px;
border: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2) outline: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
border-radius: 8px; border-radius: 8px;
background-color: oklch(1 0 0 / 60%); background-color: oklch(1 0 0 / 60%);
@@ -76,3 +119,12 @@ section.feature-block {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
} }
* {
--selection-outline-color: light-dark(#{list.nth(library.$brand_palette, 5)}, #{list.nth(library.$brand_palette, 6)});
}
*:focus-visible {
outline: 2px solid var(--selection-outline-color);
outline-offset: 2px;
}
+31
View File
@@ -1 +1,32 @@
@forward 'functions'; @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),
];
*/