frontend(modals): consolidate settings modals and drop page-settings

This commit is contained in:
Andras Schmelczer 2026-05-31 10:49:26 +01:00
parent 9b8bb96001
commit e3dcf75eb5
4 changed files with 178 additions and 198 deletions

View file

@ -2,6 +2,7 @@ import {
Component,
ChangeDetectionStrategy,
output,
input,
signal,
AfterViewInit,
OnDestroy,
@ -22,8 +23,20 @@ import { ModalStateService } from '../../services/modal-state.service';
class="modal"
[class.active]="active()"
(click)="onBackdropClick($event)"
(keydown.enter)="onBackdropClick($any($event))"
tabindex="-1"
>
<div class="modal__dialog" #dialog cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="onClose()">
<div
class="modal__dialog"
#dialog
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="labelledBy()"
[attr.aria-describedby]="describedBy()"
cdkTrapFocus
cdkTrapFocusAutoCapture
(keydown.escape)="onClose()"
>
<ng-content></ng-content>
</div>
</section>
@ -31,19 +44,35 @@ import { ModalStateService } from '../../services/modal-state.service';
styles: `
@import '../../../library/main';
section.modal {
/* Keep the component host out of parent flex/grid flow. Parent containers
apply spacing to direct children, so relying on the fixed child alone can
still let <lt-modal> become a layout item when it mounts. */
:host {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 10000;
display: block;
margin: 0 !important;
}
section.modal {
position: absolute;
inset: 0;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient;
background: rgba(255, 248, 248, 0.94);
transition: opacity 300ms;
opacity: 1;
overflow-y: auto;
@media (max-width: $mobile-width) {
padding: var(--medium-padding);
}
@media (max-height: $min-height) {
align-items: flex-start;
}
&:not(.active) {
opacity: 0;
@ -57,6 +86,8 @@ import { ModalStateService } from '../../services/modal-state.service';
`,
})
export class ModalComponent implements AfterViewInit, OnDestroy {
readonly labelledBy = input<string | null>(null);
readonly describedBy = input<string | null>(null);
readonly close = output<void>();
// The active signal starts false; AfterViewInit flips it true on next tick
@ -65,7 +96,6 @@ export class ModalComponent implements AfterViewInit, OnDestroy {
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
private previousFocus: HTMLElement | null = null;
private escListener!: (e: KeyboardEvent) => void;
private readonly modalState = inject(ModalStateService);
ngAfterViewInit(): void {

View file

@ -1,141 +0,0 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
OnInit,
inject,
signal,
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Page } from '../../models';
import { ToggleComponent } from '../shared/toggle/toggle.component';
export interface PageSettingsResult {
name: string;
hide_create_tower_button: boolean;
keep_tasks_open: boolean;
}
@Component({
selector: 'lt-page-settings',
standalone: true,
imports: [ReactiveFormsModule, ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="header">
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
<h2>{{ page() ? 'Page settings' : 'New page' }}</h2>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input
id="ps-name"
type="text"
formControlName="name"
maxlength="200"
autocomplete="off"
placeholder="Page name…"
/>
<div class="toggle-row">
<lt-toggle
[checked]="hideCreateTowerButton()"
(checkedChange)="hideCreateTowerButton.set($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
</div>
<div class="toggle-row">
<lt-toggle
[checked]="keepTasksOpen()"
(checkedChange)="keepTasksOpen.set($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
</div>
<button type="submit" [disabled]="form.invalid">
{{ page() ? 'Save' : 'Create page' }}
</button>
@if (page()) {
<button type="button" (click)="delete.emit()">Delete page</button>
}
</form>
`,
styles: `
@import '../../../library/main';
:host {
@include card();
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
display: block;
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
input[type='text'] {
text-align: center;
}
.toggle-row {
display: flex;
justify-content: center;
}
button {
display: block;
}
}
`,
})
export class PageSettingsComponent implements OnInit {
readonly page = input<Page | null>(null);
readonly save = output<PageSettingsResult>();
readonly delete = output<void>();
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
});
hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
ngOnInit(): void {
const p = this.page();
if (p) {
this.form.patchValue({ name: p.name });
this.hideCreateTowerButton.set(p.hide_create_tower_button);
this.keepTasksOpen.set(p.keep_tasks_open);
}
}
onSubmit(): void {
if (this.form.invalid) return;
const v = this.form.value;
this.save.emit({
name: v.name ?? '',
hide_create_tower_button: this.hideCreateTowerButton(),
keep_tasks_open: this.keepTasksOpen(),
});
}
}

View file

@ -16,9 +16,6 @@ export interface UpdatePagePayload {
keep_tasks_open: boolean;
}
const UUIDV4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Component({
selector: 'lt-settings',
standalone: true,
@ -26,7 +23,7 @@ const UUIDV4_RE =
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Settings</h2>
@if (page()) {
@ -36,26 +33,30 @@ const UUIDV4_RE =
<input
type="text"
[value]="pageName()"
(blur)="onRenamePage($any($event.target).value)"
(blur)="onRenamePage($any($event.target))"
placeholder="Page name…"
maxlength="200"
autocomplete="off"
aria-label="Page name"
/>
<lt-toggle
[checked]="hideCreateTowerButton()"
(checkedChange)="onHideCreateTowerButtonChange($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<div class="toggle-list">
<lt-toggle
class="setting-toggle"
[checked]="hideCreateTowerButton()"
(checkedChange)="onHideCreateTowerButtonChange($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<lt-toggle
[checked]="keepTasksOpen()"
(checkedChange)="onKeepTasksOpenChange($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
<lt-toggle
class="setting-toggle"
[checked]="keepTasksOpen()"
(checkedChange)="onKeepTasksOpenChange($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
</div>
<button class="danger" type="button" (click)="deletePage.emit()">
Delete this page
@ -68,7 +69,7 @@ const UUIDV4_RE =
<section class="account-section">
<h3>Account</h3>
<p class="hint">Your token (keep it secret it IS your account)</p>
<p class="hint">Copy this token to another device to permanently sync your progress</p>
<div class="token-row">
<input
type="text"
@ -111,7 +112,11 @@ const UUIDV4_RE =
@include card();
width: 66vw;
max-width: 480px;
@media (max-width: $mobile-width) { width: 300px; }
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
@ -123,17 +128,19 @@ const UUIDV4_RE =
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
font-size: 0;
}
h2 {
margin: 0 0 var(--large-padding) 0;
padding: 0 36px;
line-height: 1.3;
text-align: center;
}
h3 {
margin: 0 0 var(--medium-padding) 0;
font-size: var(--large-font-size);
font-size: var(--medium-font-size);
line-height: 1.35;
}
section {
@ -151,8 +158,37 @@ const UUIDV4_RE =
margin: var(--large-padding) 0;
}
input[type='text'],
button:not(.exit) {
font-size: var(--medium-font-size);
line-height: 1.35;
}
.toggle-list {
display: flex;
flex-direction: column;
gap: var(--small-padding);
}
lt-toggle.setting-toggle {
--toggle-label-width: 145px;
box-sizing: border-box;
justify-content: center;
width: 100%;
min-height: 52px;
padding: var(--small-padding);
border-radius: var(--border-radius);
background: rgba($text-color, 0.035);
@media (max-width: $mobile-width) {
min-height: 48px;
}
}
.hint {
font-size: var(--small-font-size);
font-size: var(--medium-font-size);
line-height: 1.35;
color: rgba($text-color, 0.7);
margin: 0 0 4px 0;
}
@ -171,6 +207,13 @@ const UUIDV4_RE =
button {
margin: 0;
flex: 0 0 auto;
max-width: 100%;
}
@media (max-width: $mobile-width) {
flex-wrap: wrap;
input { width: 100%; }
button { margin-left: auto; }
}
}
@ -209,10 +252,15 @@ export class SettingsComponent {
readonly hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
private static readonly UUIDV4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Token-switch state
readonly tokenInput = signal('');
readonly tokenInputTouched = signal(false);
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
readonly isValidToken = computed(() =>
SettingsComponent.UUIDV4_RE.test(this.tokenInput()),
);
constructor() {
effect(() => {
@ -225,10 +273,14 @@ export class SettingsComponent {
});
}
onRenamePage(value: string): void {
const trimmed = value.trim();
if (!trimmed) return;
onRenamePage(input: HTMLInputElement): void {
const trimmed = input.value.trim();
if (!trimmed) {
input.value = this.pageName();
return;
}
this.pageName.set(trimmed);
input.value = trimmed;
this.flushPageUpdate();
}

View file

@ -5,7 +5,9 @@ import {
output,
OnInit,
inject,
DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Tower, HslColor } from '../../models';
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
@ -21,10 +23,7 @@ export interface TowerSettingsResult {
imports: [ReactiveFormsModule, ColorPickerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="header">
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
<h2>{{ tower() ? 'Tower settings' : 'New tower' }}</h2>
</div>
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input
@ -32,19 +31,22 @@ export interface TowerSettingsResult {
name="towerName"
type="text"
formControlName="name"
placeholder="Tower name…"
[placeholder]="tower() ? 'Tower name…' : 'New tower'"
maxlength="200"
autocomplete="off"
class="title-input"
/>
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
<button type="submit" [disabled]="form.invalid">
{{ tower() ? 'Save' : 'Create tower' }}
</button>
<div class="picker-row">
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
</div>
@if (tower()) {
<!-- Editing an existing tower: changes auto-save, so there's no Save
button only the destructive action remains explicit. -->
<button type="button" (click)="delete.emit()">Delete tower</button>
} @else {
<button type="submit" [disabled]="form.invalid">Create tower</button>
}
</form>
`,
@ -55,30 +57,45 @@ export interface TowerSettingsResult {
@include card();
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) { width: 300px; }
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box;
padding: var(--large-padding);
padding-top: calc(var(--large-padding) + var(--medium-padding));
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
display: block;
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
}
input[type='text'] {
form {
@include inner-spacing(var(--large-padding));
}
.title-input {
@include title-text();
text-align: center;
width: 100%;
background: transparent;
border: 0;
}
button {
display: block;
// Stay full-width on mobile, but switch to flex so forms.scss's
// bottom-alignment keeps the underline hugging the label in the 42px
// tap target (plain block centres the text and strands the underline).
@media (max-width: $mobile-width) {
display: flex;
}
}
}
`,
@ -90,6 +107,7 @@ export class TowerSettingsComponent implements OnInit {
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
@ -102,17 +120,38 @@ export class TowerSettingsComponent implements OnInit {
if (t) {
this.form.patchValue({ name: t.name });
this.currentColor = { ...t.base_color };
// Edit mode: persist name changes as they happen. Wire this up *after*
// the initial patchValue so seeding the form doesn't fire a save.
this.form.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.autoSave());
}
}
onColorChange(color: HslColor): void {
this.currentColor = color;
// In edit mode the picker is a live control — commit each change.
if (this.tower()) this.autoSave();
}
onSubmit(): void {
// Only the create flow reaches here via its Submit button; edit mode
// auto-saves (and Enter on the single field is a harmless redundant save).
this.tryEmitSave();
}
private autoSave(): void {
this.tryEmitSave();
}
private tryEmitSave(): void {
if (this.form.invalid) return;
const v = this.form.value;
this.save.emit({ name: v.name ?? '', base_color: this.currentColor });
this.emitSave();
}
private emitSave(): void {
this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor });
}
}