Compare commits
25
Commits
0baba36f7f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe01a365a8 | ||
|
|
f68aa880db | ||
|
|
a2ec29adca | ||
|
|
0908064688 | ||
|
|
25a9bca291 | ||
|
|
7363684b2f | ||
|
|
2f5e79e300 | ||
|
|
912bd0d755 | ||
|
|
e01e628d94 | ||
|
|
214dfb498e | ||
|
|
f343f3e977 | ||
|
|
1a49cd132b | ||
|
|
713f0ef75b | ||
|
|
3bece368b3 | ||
|
|
405f2808a0 | ||
|
|
88d2b6a546 | ||
|
|
e86f500d8f | ||
|
|
31f78de70b | ||
|
|
96894c81e0 | ||
|
|
1885ed3926 | ||
|
|
96f62b0e7c | ||
|
|
0586005191 | ||
|
|
9092c97b9c | ||
|
|
c21845a579 | ||
|
|
5de6d111f2 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트
|
||||
|
||||
$IMAGE_NAME = "aef-component-test"
|
||||
$TAG = "0.0.8"
|
||||
$TAG = "0.0.9"
|
||||
$OUTPUT_FILE = "aef-component-test.tar"
|
||||
|
||||
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<skip-links ngSkipHydration>
|
||||
<button (click)="skipToContent()">{{ translation.common.skipToContent }}</button>
|
||||
</skip-links>
|
||||
|
||||
<header-menu></header-menu>
|
||||
|
||||
<main #content>
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<div id="global-popup-root" class="popup-outlet">
|
||||
@for (popup of popupService.activePopups(); track popup.component) {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Routes } from '@angular/router';
|
||||
import { MainPage } from './pages/main/main';
|
||||
import { KnownIssuesPage } from './pages/known-issues/known-issues';
|
||||
import { ClockPage } from './pages/clock/clock';
|
||||
import { LevelPage } from './pages/levels/levels';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: MainPage },
|
||||
{ path: 'known-issues', component: KnownIssuesPage },
|
||||
{ path: 'clock', component: ClockPage },
|
||||
{ path: 'levels', component: LevelPage },
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
:host {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
main:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
+24
-6
@@ -1,28 +1,33 @@
|
||||
import { Component, inject, effect, PLATFORM_ID } from '@angular/core';
|
||||
import { Component, inject, effect, PLATFORM_ID, ElementRef, viewChild } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { HeaderMenu } from './features/header-menu/header-menu';
|
||||
import { PopupService } from './services/popup.service';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { NgComponentOutlet, isPlatformBrowser, isPlatformServer, DOCUMENT } from '@angular/common';
|
||||
import { SkipLinks } from './components/skip-links/skip-links';
|
||||
import { TranslationService } from './services/translation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet],
|
||||
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {
|
||||
protected popupService = inject(PopupService);
|
||||
protected settingsService = inject(SettingsService);
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
protected translation = inject(TranslationService).translation;
|
||||
private platformID = inject(PLATFORM_ID);
|
||||
|
||||
private contentElement = viewChild<ElementRef<HTMLElement>>("content");
|
||||
|
||||
private document = inject(DOCUMENT);
|
||||
private rootHtmlElement!: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
if (isPlatformServer(this.platformId)) {
|
||||
if (isPlatformServer(this.platformID)) {
|
||||
this.rootHtmlElement = this.document.documentElement;
|
||||
} else if (isPlatformBrowser(this.platformId)) {
|
||||
} else if (isPlatformBrowser(this.platformID)) {
|
||||
this.rootHtmlElement = document.documentElement;
|
||||
}
|
||||
|
||||
@@ -37,9 +42,22 @@ export class App {
|
||||
this.rootHtmlElement.classList.toggle('light-theme', !isDark);
|
||||
|
||||
const lang = this.settingsService.language();
|
||||
this.rootHtmlElement.lang = lang.toLowerCase();
|
||||
const langMap: Record<string, string> = {
|
||||
KR: 'ko',
|
||||
JP: 'ja',
|
||||
EN: 'en',
|
||||
};
|
||||
this.rootHtmlElement.lang = langMap[lang] || 'en';
|
||||
|
||||
const reduceMotion = this.settingsService.reduceAnimation();
|
||||
this.rootHtmlElement.classList.toggle('reduce-motion', reduceMotion);
|
||||
}
|
||||
|
||||
skipToContent() {
|
||||
this.contentElement()!.nativeElement.tabIndex = 0;
|
||||
this.contentElement()!.nativeElement.focus();
|
||||
|
||||
// @ts-ignore
|
||||
this.contentElement()!.nativeElement.tabIndex = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
afterNextRender, ChangeDetectionStrategy,
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
viewChild,
|
||||
@@ -24,6 +26,9 @@ export class BrandAccent implements OnDestroy {
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
drawGrid = input<boolean>(true);
|
||||
|
||||
private bgRendererScale = 1;
|
||||
private canvasNativeSize: [number, number] = [0, 0];
|
||||
private bgGradient!: CanvasGradient;
|
||||
private gridGradient!: CanvasGradient;
|
||||
@@ -119,8 +124,8 @@ export class BrandAccent implements OnDestroy {
|
||||
canvas.height = height * dpr;
|
||||
|
||||
if (this.offscreenCanvas) {
|
||||
this.offscreenCanvas.width = width;
|
||||
this.offscreenCanvas.height = height;
|
||||
this.offscreenCanvas.width = width * this.bgRendererScale;
|
||||
this.offscreenCanvas.height = height * this.bgRendererScale;
|
||||
}
|
||||
|
||||
this.canvasNativeSize = [width, height];
|
||||
@@ -149,7 +154,12 @@ export class BrandAccent implements OnDestroy {
|
||||
if (w === 0 || h === 0) return;
|
||||
|
||||
if (!isDark) {
|
||||
this.bgGradient = this.ctx.createLinearGradient(0, 0, w, h);
|
||||
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)');
|
||||
}
|
||||
@@ -173,15 +183,15 @@ export class BrandAccent implements OnDestroy {
|
||||
const h = this.canvasNativeSize[1];
|
||||
if (w === 0 || h === 0) return;
|
||||
|
||||
this.offscreenCtx.clearRect(0, 0, w, h);
|
||||
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, h);
|
||||
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, h);
|
||||
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))];
|
||||
|
||||
@@ -192,13 +202,17 @@ export class BrandAccent implements OnDestroy {
|
||||
Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) /
|
||||
this.rendererScale;
|
||||
|
||||
this.offscreenCtx.filter = `blur(${blurScale}px)`;
|
||||
this.offscreenCtx.filter = `blur(${blurScale * this.bgRendererScale}px)`;
|
||||
|
||||
this.offscreenCtx.beginPath();
|
||||
this.offscreenCtx.ellipse(
|
||||
ellipseX, ellipseY,
|
||||
ellipseDimension[0] / 2, ellipseDimension[1] / 2,
|
||||
0, 0, 2 * Math.PI,
|
||||
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();
|
||||
@@ -230,11 +244,19 @@ export class BrandAccent implements OnDestroy {
|
||||
this.ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
|
||||
this.ctx.drawImage(
|
||||
this.offscreenCanvas,
|
||||
0,
|
||||
0,
|
||||
this.canvasNativeSize[0],
|
||||
this.canvasNativeSize[1],
|
||||
);
|
||||
|
||||
// this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
||||
}
|
||||
|
||||
if (!this.drawGrid()) return;
|
||||
|
||||
if (!isDark) {
|
||||
this.ctx.globalCompositeOperation = 'multiply';
|
||||
} else {
|
||||
@@ -255,7 +277,7 @@ export class BrandAccent implements OnDestroy {
|
||||
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
|
||||
2;
|
||||
|
||||
const movingOffset = -(this.animation.gridSquareSize * this.animation.progress);
|
||||
const movingOffset = this.settingsService.reduceAnimation() ? 0 : -(this.animation.gridSquareSize * this.animation.progress);
|
||||
|
||||
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
|
||||
this.ctx.moveTo(
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<ng-template #inputTemplate>
|
||||
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
|
||||
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
|
||||
[attr.placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" tabindex="-1" spellcheck="false"
|
||||
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"
|
||||
(focus)="onInputFocusIn()" (focusout)="onInputFocusOut()"></div>
|
||||
</ng-template>
|
||||
|
||||
<div class="dropdown-box">
|
||||
@if (icon()) {
|
||||
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
|
||||
@if (icon()?.trim()?.length ?? 0 > 0) {
|
||||
<span class="material-symbols-outlined icon-24 prefix-icon" aria-hidden="true">{{ icon() }}</span>
|
||||
}
|
||||
|
||||
@if (isOpen()) {
|
||||
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
|
||||
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="translation.common.selectOption"
|
||||
[attr.placeholder]="translation.common.selectOption" tabindex="-1" spellcheck="false"
|
||||
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)">{{ this.initialInputText() }}</div>
|
||||
} @else {
|
||||
<span class="placeholder">{{ selectedLabel() }}</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" [class.open]="isOpen()">keyboard_arrow_down</span>
|
||||
<span class="material-symbols-outlined icon-24 chevron" aria-hidden="true" [class.open]="isOpen()">keyboard_arrow_down</span>
|
||||
</div>
|
||||
|
||||
@if (isOpen()) {
|
||||
<div class="dropdown-container">
|
||||
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'">
|
||||
<div class="dropdown-container" tabindex="-1">
|
||||
<ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
|
||||
@for (option of filteredOptions(); track option.value; let i = $index) {
|
||||
<li
|
||||
#optionItem
|
||||
class="dropdown-item"
|
||||
role="option"
|
||||
[id]="dropdownId + '-option-' + i"
|
||||
[id]="dropdownID + '-option-' + i"
|
||||
[class.selected]="option.value === value()"
|
||||
[class.focused]="i === focusedIndex()"
|
||||
[attr.aria-selected]="option.value === value()"
|
||||
|
||||
@@ -39,8 +39,8 @@ $default_animation_duration: 0.2s;
|
||||
|
||||
max-width: 100%;
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid list.nth(library.$brand_palette, 5);
|
||||
&:has(.dropdown-input.focus-mode:focus-visible) {
|
||||
outline: 2px solid var(--selection-outline-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -61,23 +61,24 @@ $default_animation_duration: 0.2s;
|
||||
|
||||
.placeholder {
|
||||
text-overflow: ellipsis;
|
||||
&.value-empty { opacity: 50%; }
|
||||
}
|
||||
|
||||
.dropdown-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.dropdown-input {
|
||||
cursor: text;
|
||||
|
||||
.dropdown-input:empty:before {
|
||||
&:focus-visible { outline: none; }
|
||||
&:empty:before {
|
||||
content: attr(placeholder);
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
.dropdown-input:not(.focus-mode) {
|
||||
&:not(.focus-mode) {
|
||||
caret-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dropdown-input.hidden {
|
||||
&.hidden {
|
||||
position: absolute;
|
||||
|
||||
width: 0;
|
||||
@@ -85,9 +86,6 @@ $default_animation_duration: 0.2s;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dropdown-input {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.placeholder, .dropdown-input {
|
||||
@@ -123,7 +121,7 @@ $default_animation_duration: 0.2s;
|
||||
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
||||
border-radius: $border_radius;
|
||||
z-index: 1000;
|
||||
max-height: 240px;
|
||||
max-height: 200px;
|
||||
overflow-y: hidden;
|
||||
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
|
||||
animation: slideIn var(--animation-duration) $animation_timing;
|
||||
@@ -186,7 +184,7 @@ $default_animation_duration: 0.2s;
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
align-content: center;
|
||||
transition: --hover-state var(--animation-duration) $animation_timing, --selected-state var(--animation-duration) $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(
|
||||
@@ -197,17 +195,21 @@ $default_animation_duration: 0.2s;
|
||||
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, 11))
|
||||
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-state: 1;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
--selected-state: 1;
|
||||
color: list.nth(library.$brand_palette, 5);
|
||||
color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
output,
|
||||
signal,
|
||||
HostListener,
|
||||
ElementRef,
|
||||
computed,
|
||||
OnInit,
|
||||
inject,
|
||||
effect,
|
||||
viewChild,
|
||||
output,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
viewChildren
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||
import { TranslationService } from '../../services/translation.service';
|
||||
|
||||
export interface DropdownOption {
|
||||
@@ -31,14 +33,13 @@ export interface DropdownOption {
|
||||
role: 'combobox',
|
||||
'aria-haspopup': 'listbox',
|
||||
'[attr.aria-expanded]': 'isOpen()',
|
||||
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null",
|
||||
'[attr.aria-activedescendant]':
|
||||
"isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
|
||||
'[attr.aria-controls]': "isOpen() ? dropdownID + '-listbox' : null",
|
||||
'[attr.aria-activedescendant]': 'ariaActiveDescendant()',
|
||||
'[attr.aria-label]': 'ariaLabel()',
|
||||
'[class.open]': 'isOpen()',
|
||||
tabindex: '0',
|
||||
'(click)': 'toggle()',
|
||||
'(focus)': 'onFocus()',
|
||||
'(focusin)': 'onFocusIn($event)',
|
||||
'(mousedown)': 'onMouseDown($event)',
|
||||
'(keydown)': 'onKeyDown($event)',
|
||||
'(keydown.enter)': 'onEnter($event)',
|
||||
@@ -50,10 +51,12 @@ export interface DropdownOption {
|
||||
})
|
||||
export class Dropdown implements OnInit {
|
||||
translation = inject(TranslationService).translation;
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
searchInput = viewChild<ElementRef<HTMLElement>>('searchInput');
|
||||
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
|
||||
|
||||
options = input<Array<DropdownOption | string>>([]);
|
||||
placeholder = input<string>('Select an option');
|
||||
placeholder = input<string>('');
|
||||
ariaLabel = input<string | undefined>(undefined);
|
||||
icon = input<string>();
|
||||
defaultIndex = input<number>();
|
||||
@@ -64,21 +67,47 @@ export class Dropdown implements OnInit {
|
||||
inputFocusMode = signal(false);
|
||||
focusedIndex = signal(-1);
|
||||
searchQuery = signal('');
|
||||
recreateKey = signal(0);
|
||||
|
||||
private static nextId = 0;
|
||||
ariaActiveDescendant = computed(() => {
|
||||
return this.isOpen() && this.focusedIndex() >= 0
|
||||
? this.dropdownID + '-option-' + this.focusedIndex()
|
||||
: null;
|
||||
});
|
||||
|
||||
private static nextID = 0;
|
||||
private isLatestMouseDownInsideInput = false;
|
||||
dropdownId = `dropdown-${Dropdown.nextId++}`;
|
||||
dropdownID = `dropdown-${Dropdown.nextID++}`;
|
||||
private isLatestMouseDownInsideContainer = false;
|
||||
|
||||
constructor(private element: ElementRef) {
|
||||
effect(() => this.filteredOptions());
|
||||
|
||||
effect(() => {
|
||||
const index = this.focusedIndex();
|
||||
const isDropdownOpen = this.isOpen();
|
||||
|
||||
if (isDropdownOpen && index >= 0) {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollToIndex(index);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const isDropdownOpen = this.isOpen();
|
||||
|
||||
if (!isDropdownOpen) {
|
||||
if (untracked(this.inputFocusMode)) this.inputFocusMode.set(false);
|
||||
this.searchQuery.set('');
|
||||
|
||||
const input = this.searchInput();
|
||||
if (isPlatformBrowser(this.platformId) && input) {
|
||||
let currentValue = untracked(this.normalizedOptions).find(
|
||||
(o) => o.value === this.value(),
|
||||
);
|
||||
if (currentValue) input.nativeElement.innerText = currentValue.label;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -93,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(() => {
|
||||
return this.options().map((opt) => {
|
||||
if (typeof opt === 'string') {
|
||||
@@ -112,41 +152,51 @@ export class Dropdown implements OnInit {
|
||||
return opts.filter((o) => o.label.toLowerCase().normalize('NFKD').includes(query));
|
||||
});
|
||||
|
||||
initialInputText = computed<string>(() => {
|
||||
let currentValue = this.normalizedOptions().find((o) => o.value === this.value())?.label;
|
||||
return currentValue ? currentValue : "";
|
||||
})
|
||||
|
||||
toggle() {
|
||||
if (this.isLatestMouseDownInsideInput) {
|
||||
this.isLatestMouseDownInsideInput = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen.update((v) => !v);
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.isOpen.set(true);
|
||||
this.searchQuery.set('');
|
||||
|
||||
const currentIndex = this.normalizedOptions().findIndex((o) => o.value === this.value());
|
||||
this.focusedIndex.set(currentIndex);
|
||||
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
|
||||
|
||||
setTimeout(() => {
|
||||
const searchInputElement = this.searchInput()!.nativeElement;
|
||||
let wasFocusVisible = this.element.nativeElement.matches(':focus-visible');
|
||||
this.focusSearchInput();
|
||||
|
||||
searchInputElement.focus();
|
||||
this.setCursorToEnd(searchInputElement);
|
||||
if (wasFocusVisible) this.inputFocusMode.set(true);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
onFocus() {
|
||||
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(
|
||||
@@ -156,7 +206,14 @@ export class Dropdown implements OnInit {
|
||||
}
|
||||
|
||||
close() {
|
||||
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
|
||||
const isDropdownOpen = this.isOpen();
|
||||
|
||||
this.isOpen.set(false);
|
||||
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
|
||||
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
|
||||
|
||||
this.element.nativeElement.tabIndex = undefined;
|
||||
this.focusedIndex.set(-1);
|
||||
}
|
||||
|
||||
@@ -193,6 +250,8 @@ export class Dropdown implements OnInit {
|
||||
|
||||
onArrowDown(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.toggle();
|
||||
return;
|
||||
@@ -209,6 +268,8 @@ export class Dropdown implements OnInit {
|
||||
|
||||
onArrowUp(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.toggle();
|
||||
return;
|
||||
@@ -248,7 +309,8 @@ export class Dropdown implements OnInit {
|
||||
onDocumentClick(event: MouseEvent) {
|
||||
if (
|
||||
!this.element.nativeElement.contains(event.target) &&
|
||||
!this.isLatestMouseDownInsideContainer
|
||||
!this.isLatestMouseDownInsideContainer &&
|
||||
this.isOpen()
|
||||
) {
|
||||
this.close();
|
||||
}
|
||||
@@ -290,8 +352,12 @@ export class Dropdown implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
selectedOption = computed(() => {
|
||||
return this.normalizedOptions().find((o) => o.value === this.value());
|
||||
})
|
||||
|
||||
selectedLabel = computed(() => {
|
||||
const selected = this.normalizedOptions().find((o) => o.value === this.value());
|
||||
const selected = this.selectedOption();
|
||||
return selected ? selected.label : this.placeholder();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<section class="information">
|
||||
<div class="soundtrack">
|
||||
<span class="artist">{{ artist() }}</span>
|
||||
<span class="track-name">{{ trackName() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="other">
|
||||
<div class="creator">
|
||||
<span>by</span>
|
||||
<span>{{ creatorString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="buttons">
|
||||
<button [aria-label]="previewButtonLabel()">
|
||||
<div class="material-symbols-outlined icon-40 button-icon">play_arrow</div>
|
||||
</button>
|
||||
<button [aria-label]="downloadButtonLabel()">
|
||||
<div class="material-symbols-outlined icon-40 button-icon">download</div>
|
||||
</button>
|
||||
</section>
|
||||
@@ -0,0 +1,162 @@
|
||||
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
$default_animation_duration: 0.2s;
|
||||
|
||||
@property --hover-state {
|
||||
syntax: '<number>';
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
:host {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 160px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
|
||||
justify-content: space-between;
|
||||
|
||||
border-radius: 8px;
|
||||
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
||||
// overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: 0;
|
||||
outline: 2px solid var(--selection-outline-color);
|
||||
}
|
||||
|
||||
section.information {
|
||||
width: calc(100% - 64px);
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
|
||||
z-index: 1;
|
||||
background-color: light-dark(white, black);
|
||||
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
// outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 1px solid;
|
||||
|
||||
.soundtrack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.artist {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
height: 20px;
|
||||
align-content: center;
|
||||
font-weight: 700;
|
||||
|
||||
white-space: nowrap;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
align-self: stretch;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-wrap: break-word;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
line-height: 22px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.other {
|
||||
display: flex;
|
||||
width: 312px;
|
||||
height: 68px;
|
||||
padding: 3px 0;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.creator {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 14px;
|
||||
height: 20px;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
align-content: center;
|
||||
&:nth-child(2) {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section.buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
|
||||
flex-grow: 1;
|
||||
margin-left: 0;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
border: none;
|
||||
transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing;
|
||||
background-color: color-mix(
|
||||
in oklch,
|
||||
light-dark(oklch(1 0 0), oklch(0 0 0)),
|
||||
light-dark(oklch(0.9702 0 0), oklch(0.25 0 0))
|
||||
calc(var(--hover-state) * 100%)
|
||||
);
|
||||
|
||||
padding: 0;
|
||||
&:hover {
|
||||
--hover-state: 1;
|
||||
}
|
||||
|
||||
&:not(:focus-visible) {
|
||||
box-shadow: -1px 0 0 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
z-index: 11;
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
div.button-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { TranslationService } from '../../services/translation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'level-card',
|
||||
imports: [],
|
||||
templateUrl: './level-card.html',
|
||||
styleUrl: './level-card.scss',
|
||||
host: {
|
||||
role: 'article',
|
||||
},
|
||||
})
|
||||
export class LevelCard {
|
||||
private translationSvc = inject(TranslationService);
|
||||
protected translation = this.translationSvc.translation;
|
||||
|
||||
artist = input.required<string>();
|
||||
trackName = input.required<string>();
|
||||
creator = input<string[]>([]);
|
||||
|
||||
creatorString = computed(() => this.creator().join(' & '));
|
||||
|
||||
previewButtonLabel = computed(() =>
|
||||
this.translationSvc.interpolate(this.translation.levelCard.preview, {
|
||||
artist: this.artist(),
|
||||
trackName: this.trackName(),
|
||||
}),
|
||||
);
|
||||
|
||||
downloadButtonLabel = computed(() =>
|
||||
this.translationSvc.interpolate(this.translation.levelCard.download, {
|
||||
creator: this.creatorString()
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<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.custom-pos]="!isCenter"
|
||||
[style.top]="customStyle.top"
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ $default_animation_duration: 0.2s;
|
||||
animation: popup-enter var(--animation-duration) $animation_timing;
|
||||
|
||||
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
|
||||
&.variant-glass {
|
||||
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
|
||||
@@ -52,6 +52,11 @@ $default_animation_duration: 0.2s;
|
||||
&.custom-pos {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: 0;
|
||||
outline: 2px solid var(--selection-outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popup-enter {
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Component, input, output, HostListener } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
HostListener,
|
||||
ElementRef,
|
||||
inject,
|
||||
viewChild,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { TranslationService } from '../../services/translation.service';
|
||||
|
||||
export type PopupPosition = {
|
||||
top?: string;
|
||||
@@ -15,12 +26,16 @@ export type PopupVariant = 'glass' | 'solid';
|
||||
templateUrl: './popup.html',
|
||||
styleUrl: './popup.scss',
|
||||
host: {
|
||||
'role': 'dialog',
|
||||
role: 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'tabindex': '-1'
|
||||
}
|
||||
tabindex: '-1',
|
||||
},
|
||||
})
|
||||
export class PopupComponent {
|
||||
export class PopupComponent implements OnInit, OnDestroy {
|
||||
private translation = inject(TranslationService).translation;
|
||||
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
|
||||
private previouslyFocusedElement: HTMLElement | null = null;
|
||||
|
||||
/**
|
||||
* 팝업의 위치 (기본값은 'center')
|
||||
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
|
||||
@@ -32,14 +47,102 @@ export class PopupComponent {
|
||||
*/
|
||||
variant = input<PopupVariant>('glass');
|
||||
|
||||
/**
|
||||
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
|
||||
*/
|
||||
closeButtonLabel = input<string>(this.translation.common.close);
|
||||
|
||||
/**
|
||||
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
|
||||
*/
|
||||
closeRequest = output<void>();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onKeydownHandler() {
|
||||
this.onClose();
|
||||
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() {
|
||||
|
||||
@@ -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 {}
|
||||
@@ -30,14 +30,14 @@ $default_animation_duration: 0.2s;
|
||||
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, 8))
|
||||
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, 10))
|
||||
light-dark($border_color, list.nth(library.$brand_palette, 9))
|
||||
calc(var(--animation-state) * 100%)
|
||||
) inset;
|
||||
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<div class="menu-left">
|
||||
<div #menuLeft class="menu-left">
|
||||
<a class="brand" [routerLink]="['/']">
|
||||
<img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
|
||||
<span #brandText [class.hidden]="!hasExtraSpace()">
|
||||
<span #brandText [class.hidden]="expandStage() < 2">
|
||||
<strong>ADOFAI Editor Forum</strong>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<span class="menu-item dummy-link">{{ translation.menu.levels }}</span>
|
||||
<div #leftMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
|
||||
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-right" #menuRight>
|
||||
<div #menuRight class="menu-right">
|
||||
<div #rightMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
|
||||
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
|
||||
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" [class.hidden]="expandStage() > 0" [attr.inert]="expandStage() > 0 ? '' : null" class="material-symbols-outlined icon-40 menu" (click)="onHamburgerClick()" [attr.aria-label]="translation.menu.menu">
|
||||
menu
|
||||
</button>
|
||||
|
||||
<button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
|
||||
settings
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
height: 64px;
|
||||
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||
@@ -26,11 +27,24 @@
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
|
||||
.menu-items {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
|
||||
&.hidden {
|
||||
position: absolute;
|
||||
top: -100000px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menu-item, a:not(.settings) {
|
||||
.menu-item, a {
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
height: 40px;
|
||||
@@ -42,10 +56,11 @@
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&.nav-link {
|
||||
font-weight: 700;
|
||||
color: oklch(0.5 0.15 250);
|
||||
color: oklch(0.6 0.15 250);
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:hover {
|
||||
@@ -62,7 +77,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
.settings, .menu {
|
||||
border: none;
|
||||
border-radius: 100%;
|
||||
background-color: inherit;
|
||||
@@ -71,6 +86,9 @@
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings {
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
@@ -78,6 +96,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
&.hidden {
|
||||
position: absolute;
|
||||
top: -100000px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 구체적인 아이콘 예외 처리 */
|
||||
.icon-40 {
|
||||
height: auto;
|
||||
@@ -85,6 +111,9 @@
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
justify-content: start;
|
||||
padding: 12px;
|
||||
|
||||
@@ -104,7 +133,7 @@
|
||||
img {
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
padding: 10px 10px 10px 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
@@ -113,9 +142,11 @@
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
padding-left: 10px;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -100000px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -123,8 +154,10 @@
|
||||
}
|
||||
|
||||
.menu-right {
|
||||
flex-grow: 1;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
justify-content: flex-end;
|
||||
padding: 12px 20px;
|
||||
padding: 12px 20px 12px 12px;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PopupService } from '../../services/popup.service';
|
||||
import { SettingsPopup } from '../settings/settings';
|
||||
import { NavigationMenu } from './navigation-menu';
|
||||
import { TranslationService } from '../../services/translation.service';
|
||||
import { SettingsService } from '../../services/settings.service';
|
||||
|
||||
@@ -21,101 +23,140 @@ import { SettingsService } from '../../services/settings.service';
|
||||
templateUrl: './header-menu.html',
|
||||
styleUrl: './header-menu.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class.folded-menu]': 'expandStage() < 1',
|
||||
},
|
||||
})
|
||||
export class HeaderMenu implements OnDestroy {
|
||||
private popupService = inject(PopupService);
|
||||
protected translation = inject(TranslationService).translation;
|
||||
protected settings = inject(SettingsService);
|
||||
|
||||
menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
|
||||
brandText = viewChild<ElementRef<HTMLElement>>('brandText');
|
||||
private static readonly LOGICAL_GAP = 24;
|
||||
|
||||
hasExtraSpace = signal(true);
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private brandTextObserver?: ResizeObserver;
|
||||
private menuItemsObserver?: ResizeObserver;
|
||||
private menuWidth: number = 99999;
|
||||
|
||||
private menuResizeObserver: ResizeObserver | null = null;
|
||||
private textResizeObserver: ResizeObserver | null = null;
|
||||
private menuLeft = viewChild<ElementRef<HTMLElement>>('menuLeft');
|
||||
private leftMenuItems = viewChild<ElementRef<HTMLElement>>('leftMenuItems');
|
||||
private menuLeftWidth: number = 0;
|
||||
private leftMenuItemsWidth: number = 0;
|
||||
|
||||
private childWidths = new Map<Element, number>();
|
||||
private cachedTextWidth = 0;
|
||||
private cachedRightMenuContentWidth = 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);
|
||||
}
|
||||
});
|
||||
|
||||
constructor(private ngZone: NgZone) {
|
||||
afterNextRender(() => {
|
||||
const rightElement = this.menuRight()?.nativeElement;
|
||||
const textElement = this.brandText()?.nativeElement;
|
||||
if (!rightElement || !textElement) return;
|
||||
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
// 1. Right Menu & Children Observer
|
||||
this.menuResizeObserver = new ResizeObserver((entries) => {
|
||||
let containerWidth = -1;
|
||||
let contentChanged = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.target === rightElement) {
|
||||
containerWidth = entry.borderBoxSize[0].inlineSize;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
const width = entry.borderBoxSize[0].inlineSize;
|
||||
if (this.childWidths.get(entry.target) !== width) {
|
||||
this.childWidths.set(entry.target, width);
|
||||
contentChanged = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentChanged) this.updateCachedRightMenuContentWidth();
|
||||
if (containerWidth !== -1) this.resize(containerWidth);
|
||||
this.updateExpandStatus();
|
||||
});
|
||||
|
||||
this.menuResizeObserver.observe(rightElement);
|
||||
Array.from(rightElement.children).forEach((child) => {
|
||||
this.childWidths.set(child, (child as HTMLElement).offsetWidth);
|
||||
this.menuResizeObserver?.observe(child);
|
||||
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();
|
||||
});
|
||||
|
||||
// 2. Brand Text Observer
|
||||
this.textResizeObserver = new ResizeObserver((entries) => {
|
||||
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.cachedTextWidth = width;
|
||||
if (width > 0) this.brandTextWidth = width;
|
||||
|
||||
this.updateExpandStatus();
|
||||
});
|
||||
|
||||
this.textResizeObserver.observe(textElement);
|
||||
|
||||
// Initial calculation
|
||||
this.cachedTextWidth = textElement.offsetWidth;
|
||||
this.updateCachedRightMenuContentWidth();
|
||||
this.resize(rightElement.offsetWidth);
|
||||
this.brandTextObserver.observe(this.brandText()!.nativeElement);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.menuResizeObserver?.disconnect();
|
||||
this.textResizeObserver?.disconnect();
|
||||
this.childWidths.clear();
|
||||
this.resizeObserver?.disconnect();
|
||||
this.menuItemsObserver?.disconnect();
|
||||
this.brandTextObserver?.disconnect();
|
||||
}
|
||||
|
||||
private updateCachedRightMenuContentWidth() {
|
||||
const gap = 48;
|
||||
const widths = Array.from(this.childWidths.values());
|
||||
if (widths.length === 0) {
|
||||
this.cachedRightMenuContentWidth = 0;
|
||||
return;
|
||||
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;
|
||||
}
|
||||
|
||||
this.cachedRightMenuContentWidth =
|
||||
widths.reduce((acc, w) => acc + w, 0) + (widths.length - 1) * gap;
|
||||
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);
|
||||
}
|
||||
|
||||
private resize(availableWidth: number) {
|
||||
const hadSpace = this.hasExtraSpace();
|
||||
|
||||
const buffer = 20 * 2 + 24; // Internal Padding + Logical Gap
|
||||
const threshold = hadSpace ? 0 : this.cachedTextWidth;
|
||||
const hasSpace = availableWidth > this.cachedRightMenuContentWidth + threshold + buffer;
|
||||
|
||||
if (hadSpace !== hasSpace) this.ngZone.run(() => this.hasExtraSpace.set(hasSpace));
|
||||
onHamburgerClick(): void {
|
||||
this.popupService.open(NavigationMenu, {
|
||||
position: { top: '84px', right: '20px' },
|
||||
});
|
||||
}
|
||||
|
||||
onSettingsClick() {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<app-popup [position]="position()" (closeRequest)="close()">
|
||||
<div header-left class="menu-header-title">
|
||||
<span class="material-symbols-outlined">menu</span>
|
||||
<span>{{ translation.menu.menu }}</span>
|
||||
</div>
|
||||
|
||||
<div class="menu-content">
|
||||
<div class="menu-section">
|
||||
<a class="menu-item nav-link" [routerLink]="['/levels']" (click)="close()">{{ translation.menu.levels }}</a>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
||||
</div>
|
||||
|
||||
<hr class="menu-divider" />
|
||||
|
||||
<div class="menu-section">
|
||||
<a class="menu-item nav-link" [routerLink]="['/known-issues']" (click)="close()">{{ translation.menu.knownIssues }}</a>
|
||||
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
|
||||
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</app-popup>
|
||||
@@ -0,0 +1,73 @@
|
||||
:host {
|
||||
display: block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menu-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 276px;
|
||||
padding-bottom: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
border: none;
|
||||
background-color: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
height: 40px;
|
||||
padding-left: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 4px;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 24px;
|
||||
color: light-dark(oklch(0.4 0 0), oklch(0.8 0 0));
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.nav-link {
|
||||
font-weight: 700;
|
||||
color: oklch(0.6 0.15 250);
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&.dummy-link {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
|
||||
import { PopupService } from '../../services/popup.service';
|
||||
import { TranslationService } from '../../services/translation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'navigation-menu',
|
||||
standalone: true,
|
||||
imports: [PopupComponent, RouterLink],
|
||||
templateUrl: './navigation-menu.html',
|
||||
styleUrl: './navigation-menu.scss',
|
||||
})
|
||||
export class NavigationMenu {
|
||||
private popupService = inject(PopupService);
|
||||
protected translation = inject(TranslationService).translation;
|
||||
|
||||
/**
|
||||
* 팝업의 위치
|
||||
*/
|
||||
position = input<PopupPosition | 'center'>('center');
|
||||
|
||||
close() {
|
||||
this.popupService.close(NavigationMenu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<label class="category-header">{{ headerLabel() }}</label>
|
||||
<ng-content></ng-content>
|
||||
@@ -0,0 +1,17 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
margin: 0; padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
height: 15px;
|
||||
// color: oklch(0.6830 0 0); // #999999
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@if (settingsType() == SettingsType.Switch) {
|
||||
<div class="settings-name">
|
||||
@if (labelIcon()?.trim()?.length ?? 0 > 0) {
|
||||
<span class="material-symbols-outlined icon-24 settings-icon" aria-hidden="true">{{ labelIcon() }}</span>
|
||||
}
|
||||
<label [id]="labelID">{{ labelText() }}</label>
|
||||
</div>
|
||||
<switch [isActive]="value()" (toggle)="onChange.emit($event)" [aria-labelledby]="labelID"></switch>
|
||||
} @else if (settingsType() == SettingsType.Dropdown) {
|
||||
<label [id]="labelID" class="hidden-label">{{ labelText() }}</label>
|
||||
<dropdown [options]="options()"
|
||||
[value]="value()"
|
||||
(selectionChange)="onChange.emit($event)"
|
||||
[aria-labelledby]="labelID"
|
||||
[placeholder]="placeholder()?.trim() ?? ''"
|
||||
[icon]="labelIcon()"></dropdown>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
&:has(> switch) {
|
||||
height: 32px;
|
||||
|
||||
&:nth-child(1 of .settings-container) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> dropdown) {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.settings-name {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
gap: 8px;
|
||||
|
||||
span, label {
|
||||
display: inline-block;
|
||||
font-size: 18px;
|
||||
|
||||
&.settings-icon {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
font-size: 24px;
|
||||
|
||||
/*
|
||||
line-height: 24px;
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropdown {
|
||||
width: 324px;
|
||||
}
|
||||
|
||||
.hidden-label {
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<app-popup (closeRequest)="close()" [position]="position()">
|
||||
<div header-left class="profile-section">
|
||||
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
|
||||
<span>{{ settingsService.userName() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<section class="settings-category">
|
||||
<label class="category-header">{{ translation.settings.language }}</label>
|
||||
|
||||
<div class="settings-container">
|
||||
<dropdown [options]="languages"
|
||||
[value]="settingsService.language()"
|
||||
(selectionChange)="onLanguageChange($event)"
|
||||
icon="language"></dropdown>
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<div class="settings-name">
|
||||
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span>
|
||||
<span>{{ translation.settings.romanisation }}</span>
|
||||
</div>
|
||||
<switch [isActive]="settingsService.romanisation()" (toggle)="onRomanisationChange($event)"></switch>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-category">
|
||||
<label class="category-header">{{ translation.settings.appearance }}</label>
|
||||
<div class="settings-container">
|
||||
<div class="settings-name">
|
||||
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span>
|
||||
<span>{{ translation.settings.darkMode }}</span>
|
||||
</div>
|
||||
<switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch>
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<div class="settings-name">
|
||||
<span class="material-symbols-outlined icon-24 settings-icon">motion_photos_paused</span>
|
||||
<span>{{ translation.settings.reduceAnimation }}</span>
|
||||
</div>
|
||||
<switch [isActive]="settingsService.reduceAnimation()" (toggle)="onReduceAnimationChange($event)"></switch>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</app-popup>
|
||||
@@ -1,92 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
outline: 1px solid oklch(0 0 0 / 20%);
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
padding-bottom: 12px;
|
||||
|
||||
section.settings-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
margin: 0; padding: 0;
|
||||
border: none;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
height: 15px;
|
||||
// color: oklch(0.6830 0 0); // #999999
|
||||
}
|
||||
|
||||
& > .settings-container {
|
||||
width: 100%;
|
||||
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 {
|
||||
display: inline-block;
|
||||
font-size: 18px;
|
||||
|
||||
&.settings-icon {
|
||||
display: inline-block;
|
||||
font-size: 24px;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropdown {
|
||||
width: 324px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<app-popup (closeRequest)="close()" [position]="position()">
|
||||
<div header-left class="profile-section">
|
||||
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
|
||||
<span>{{ settingsService.isGuest() ? translation.settings.guest : settingsService.userName() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<settings-category [headerLabel]="translation.settings.language">
|
||||
<settings-container
|
||||
[settingsType]="SettingsType.Dropdown"
|
||||
[options]="languages"
|
||||
[value]="settingsService.language()"
|
||||
[labelText]="translation.settings.language" labelIcon="language"
|
||||
(onChange)="onLanguageChange($event)">
|
||||
</settings-container>
|
||||
|
||||
<settings-container
|
||||
[settingsType]="SettingsType.Switch"
|
||||
[value]="settingsService.romanisation()"
|
||||
[labelText]="translation.settings.romanisation" labelIcon="match_case"
|
||||
(onChange)="onRomanisationChange($event)">
|
||||
</settings-container>
|
||||
</settings-category>
|
||||
|
||||
<settings-category [headerLabel]="translation.settings.appearance">
|
||||
<settings-container
|
||||
[settingsType]="SettingsType.Switch"
|
||||
[value]="settingsService.darkMode()"
|
||||
[labelText]="translation.settings.darkMode" labelIcon="dark_mode"
|
||||
(onChange)="onDarkModeChange($event)">
|
||||
</settings-container>
|
||||
|
||||
<settings-container
|
||||
[settingsType]="SettingsType.Switch"
|
||||
[value]="settingsService.reduceAnimation()"
|
||||
[labelText]="translation.settings.reduceAnimation" labelIcon="motion_photos_paused"
|
||||
(onChange)="onReduceAnimationChange($event)">
|
||||
</settings-container>
|
||||
</settings-category>
|
||||
</div>
|
||||
|
||||
</app-popup>
|
||||
@@ -0,0 +1,29 @@
|
||||
:host {
|
||||
display: block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
outline: 1px solid oklch(0 0 0 / 20%);
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
padding-top: 4px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { Component, inject, input, output } from '@angular/core';
|
||||
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
|
||||
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
|
||||
import { Switch } from '../../components/switch/switch';
|
||||
@@ -7,12 +7,59 @@ import { PopupService } from '../../services/popup.service';
|
||||
import { SettingsService, Language } from '../../services/settings.service';
|
||||
import { TranslationService } from '../../services/translation.service';
|
||||
|
||||
export enum SettingsType {
|
||||
Switch = 'switch', Dropdown = 'dropdown',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'settings-category',
|
||||
standalone: true,
|
||||
templateUrl: './category.html',
|
||||
styleUrl: './category.scss',
|
||||
})
|
||||
export class SettingsCategory {
|
||||
headerLabel = input<string>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'settings-container',
|
||||
standalone: true,
|
||||
templateUrl: './container.html',
|
||||
styleUrl: './container.scss',
|
||||
imports: [Switch, Dropdown],
|
||||
host: {
|
||||
// For IDE Compatibility
|
||||
'[class.settings-container]': 'true',
|
||||
},
|
||||
})
|
||||
export class SettingsContainer {
|
||||
settingsType = input.required<SettingsType>();
|
||||
protected readonly SettingsType = SettingsType;
|
||||
|
||||
labelIcon = input<string>();
|
||||
labelText = input.required<string>();
|
||||
|
||||
value = input.required<any>();
|
||||
onChange = output<any>();
|
||||
|
||||
placeholder = input<string>();
|
||||
options = input<any>();
|
||||
|
||||
private static nextID = 0;
|
||||
labelID = `settings-${SettingsContainer.nextID++}-label`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'settings-popup',
|
||||
standalone: true,
|
||||
imports: [PopupComponent, Dropdown, Switch, NgOptimizedImage],
|
||||
templateUrl: './popup.html',
|
||||
styleUrl: './popup.scss',
|
||||
imports: [
|
||||
PopupComponent,
|
||||
NgOptimizedImage,
|
||||
SettingsCategory,
|
||||
SettingsContainer,
|
||||
],
|
||||
templateUrl: './settings.html',
|
||||
styleUrl: './settings.scss',
|
||||
})
|
||||
export class SettingsPopup {
|
||||
private popupService = inject(PopupService);
|
||||
@@ -52,4 +99,6 @@ export class SettingsPopup {
|
||||
close() {
|
||||
this.popupService.close(SettingsPopup);
|
||||
}
|
||||
|
||||
protected readonly SettingsType = SettingsType;
|
||||
}
|
||||
@@ -16,11 +16,17 @@ export const EN = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+7
-1
@@ -18,10 +18,16 @@ export const JP: Translation = {
|
||||
knownIssues: '既知の問題',
|
||||
login: 'ログイン',
|
||||
signup: '新規登録',
|
||||
menu: 'メニュー',
|
||||
},
|
||||
common: {
|
||||
selectOption: '選択...',
|
||||
selectOption: 'オプションを選択...',
|
||||
close: '閉じる',
|
||||
save: '保存',
|
||||
skipToContent: '本文へスキップ',
|
||||
},
|
||||
levelCard: {
|
||||
preview: '{{artist}}の{{trackName}} プレビュー',
|
||||
download: '{{creator}}のレベルをダウンロード',
|
||||
},
|
||||
};
|
||||
+7
-1
@@ -18,10 +18,16 @@ export const KR: Translation = {
|
||||
knownIssues: '알려진 문제',
|
||||
login: '로그인',
|
||||
signup: '회원가입',
|
||||
menu: '메뉴',
|
||||
},
|
||||
common: {
|
||||
selectOption: '선택...',
|
||||
selectOption: '옵션 선택...',
|
||||
close: '닫기',
|
||||
save: '저장',
|
||||
skipToContent: '본문으로 건너뛰기',
|
||||
},
|
||||
levelCard: {
|
||||
preview: '{{artist}}의 {{trackName}} 미리듣기',
|
||||
download: '{{creator}}의 레벨 다운로드',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<h1>Level Card Test</h1>
|
||||
|
||||
<div class="container">
|
||||
<level-card
|
||||
artist="HyuN"
|
||||
trackName="Disorder (Low VFX)"
|
||||
[creator]="['YOON']"></level-card>
|
||||
<level-card
|
||||
artist="Cinamoro"
|
||||
trackName="Little Rabbit and Deliverance"
|
||||
[creator]="['한가지']"></level-card>
|
||||
<level-card
|
||||
artist="linear ring"
|
||||
trackName="Dream By Coma"
|
||||
[creator]="['Ryuha']"></level-card>
|
||||
<level-card
|
||||
artist="Plum"
|
||||
trackName="Champagne Pop"
|
||||
[creator]="['Zizak']"></level-card>
|
||||
<level-card
|
||||
artist="JinoBeats ft. JinoBeats"
|
||||
trackName="~ Black Forsaken Wings ~"
|
||||
[creator]="['Sakura']"></level-card>
|
||||
<level-card
|
||||
artist="seatrus"
|
||||
trackName="M A X I M U M B L A S T E R"
|
||||
[creator]="['TraZ']"></level-card>
|
||||
<level-card
|
||||
artist="MegaSphere"
|
||||
trackName="Inferno"
|
||||
[creator]="['-K']"></level-card>
|
||||
<level-card
|
||||
artist="Rinne"
|
||||
trackName="Aurelia"
|
||||
[creator]="['토끼로봇']"></level-card>
|
||||
<level-card
|
||||
artist="Sot-C"
|
||||
trackName="Escalation"
|
||||
[creator]="['Chartic']"></level-card>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
@scope(:host) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:host > h1 {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
:host > .container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
max-width: calc(40px * 2 + 400px * 3);
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { Switch } from '../../components/switch/switch';
|
||||
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
|
||||
import { BrandAccent } from '../../components/brand-accent/brand-accent';
|
||||
import { LevelCard } from '../../components/level-card/level-card';
|
||||
|
||||
@Component({
|
||||
selector: 'app-levels',
|
||||
standalone: true,
|
||||
imports: [LevelCard],
|
||||
templateUrl: './levels.html',
|
||||
styleUrl: './levels.scss',
|
||||
})
|
||||
export class LevelPage {
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="container">
|
||||
<brand-accent></brand-accent>
|
||||
|
||||
<div class="container">
|
||||
<h1>Component Test</h1>
|
||||
|
||||
<section id="switch-test" class="feature-block">
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:host > .container {
|
||||
display: flex;
|
||||
width: 600px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
box-sizing: border-box;
|
||||
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
@@ -8,7 +18,8 @@
|
||||
|
||||
section#dropdown-test {
|
||||
dropdown {
|
||||
width: 324px;
|
||||
width: 100%;
|
||||
max-width: 324px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +46,12 @@ div.test-container {
|
||||
brand-accent {
|
||||
position: absolute;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
top: 0;
|
||||
top: -64px;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
min-width: 100dvw;
|
||||
height: calc(100% + 64px);
|
||||
min-height: 100dvh;
|
||||
z-index: -999999;
|
||||
}
|
||||
@@ -20,6 +20,40 @@ export class MainPage {
|
||||
{ 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);
|
||||
|
||||
@@ -23,6 +23,7 @@ export class SettingsService {
|
||||
romanisation = computed(() => this._romanisation());
|
||||
userName = computed(() => this._userName());
|
||||
reduceAnimation = computed(() => this._reduceAnimation());
|
||||
isGuest = computed(() => true);
|
||||
|
||||
constructor() {
|
||||
this.request = inject(REQUEST);
|
||||
|
||||
@@ -29,4 +29,14 @@ export class TranslationService {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -39,6 +39,8 @@ body {
|
||||
margin: 0;
|
||||
overscroll-behavior-x: none;
|
||||
background-color: white;
|
||||
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.icon-24 {
|
||||
@@ -118,7 +120,11 @@ section.feature-block {
|
||||
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 list.nth(library.$brand_palette, 5);
|
||||
outline: 2px solid var(--selection-outline-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -12,5 +12,21 @@ $brand_palette: [
|
||||
oklch(0.4509 0.1688 29.86), // #9E190E
|
||||
oklch(0.3897 0.1391 29.75), // #7F180F
|
||||
oklch(0.2547 0.0913 29.02), // #450805
|
||||
oklch(0.1752 0.0717 29.02), // #280000
|
||||
];
|
||||
|
||||
/*
|
||||
$brand_palette: [
|
||||
oklch(0.9999 0.2237 35.33),
|
||||
oklch(0.9090 0.2237 35.33),
|
||||
oklch(0.8181 0.2237 35.33),
|
||||
oklch(0.7272 0.2237 35.33),
|
||||
oklch(0.6363 0.2237 35.33),
|
||||
oklch(0.5454 0.2237 35.33),
|
||||
oklch(0.4545 0.2237 35.33),
|
||||
oklch(0.3636 0.2237 35.33),
|
||||
oklch(0.2727 0.2237 35.33),
|
||||
oklch(0.1818 0.2237 35.33),
|
||||
oklch(0.0909 0.2237 35.33),
|
||||
];
|
||||
*/
|
||||
Reference in new issue
Block a user