This commit is contained in:
Andras Schmelczer 2026-05-30 16:57:56 +01:00
parent 3930982bd8
commit ad7968dadd
53 changed files with 564 additions and 1013 deletions

View file

@ -3,7 +3,6 @@ import {
ChangeDetectionStrategy,
input,
output,
inject,
signal,
computed,
effect,
@ -33,6 +32,10 @@ interface EditedValue {
difficulty: number;
}
function clampDifficulty(value: number): number {
return Math.max(1, Math.min(100, value));
}
@Component({
selector: 'lt-block-edit',
standalone: true,
@ -48,6 +51,8 @@ interface EditedValue {
class="carousel"
(scroll)="onScroll()"
(click)="onBackdropClick($event)"
(keydown.enter)="onBackdropClick($any($event))"
tabindex="-1"
>
<div class="card placeholder"></div>
@ -56,12 +61,22 @@ interface EditedValue {
class="card"
[class.active]="activeIdx() === i + 1"
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
role="button"
tabindex="0"
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
(click)="onCardClick(i + 1)"
(keydown.enter)="onCardClick(i + 1)"
(keydown.space)="$event.preventDefault(); onCardClick(i + 1)"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
<button
class="exit"
type="button"
aria-label="Close"
(click)="close.emit(); $event.stopPropagation()"
></button>
<div
class="block-dot"
[style.background-color]="colorOfTagForBlock(b.id)"
@ -83,6 +98,7 @@ interface EditedValue {
<textarea
placeholder="Write a description here…"
maxlength="10000"
[value]="editedFor(b.id).description"
(input)="updateDescription(b.id, $any($event.target).value)"
(blur)="flushExisting(b.id)"
@ -112,6 +128,7 @@ interface EditedValue {
type="button"
class="step"
aria-label="Increase difficulty"
[disabled]="editedFor(b.id).difficulty >= 100"
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
>+</button>
</div>
@ -127,12 +144,22 @@ interface EditedValue {
class="card create-card"
[class.active]="activeIdx() === blocks().length + 1"
[class.near-active]="activeIdx() === blocks().length"
role="button"
tabindex="0"
aria-label="Focus create card"
(click)="onCardClick(blocks().length + 1)"
(keydown.enter)="onCardClick(blocks().length + 1)"
(keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
<button
class="exit"
type="button"
aria-label="Close"
(click)="close.emit(); $event.stopPropagation()"
></button>
<div
class="block-dot"
[style.background-color]="colorOfNewTag()"
@ -140,7 +167,7 @@ interface EditedValue {
<h1>Create now</h1>
</div>
<div class="select-add-container">
<div class="select-add-container" [class.required-empty]="!newValue().tag">
<lt-select-add
[items]="tags()"
[selected]="newValue().tag"
@ -154,6 +181,7 @@ interface EditedValue {
<textarea
placeholder="Write a description here…"
maxlength="10000"
[value]="newValue().description"
(input)="updateNewDescription($any($event.target).value)"
></textarea>
@ -182,6 +210,7 @@ interface EditedValue {
type="button"
class="step"
aria-label="Increase difficulty"
[disabled]="newValue().difficulty >= 100"
(click)="updateNewDifficulty(1); $event.stopPropagation()"
>+</button>
</div>
@ -671,7 +700,7 @@ export class BlockEditComponent implements AfterViewInit {
}
updateDifficulty(id: string, delta: number): void {
const next = Math.max(1, this.editedFor(id).difficulty + delta);
const next = clampDifficulty(this.editedFor(id).difficulty + delta);
this.patchEdited(id, { difficulty: next });
this.flushExisting(id);
}
@ -717,7 +746,7 @@ export class BlockEditComponent implements AfterViewInit {
}
updateNewDifficulty(delta: number): void {
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
}
submitNew(): void {

View file

@ -23,6 +23,8 @@ 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"
@ -94,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,151 +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: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
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;
// 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;
}
}
}
`,
})
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

@ -36,7 +36,7 @@ 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"
@ -271,10 +271,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

@ -23,7 +23,7 @@ export interface TowerSettingsResult {
imports: [ReactiveFormsModule, ColorPickerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input

View file

@ -22,7 +22,16 @@
}
@if (!page().hide_create_tower_button) {
<div class="add-tower-wrapper">
<img class="add-tower" src="assets/plus-sign.svg" alt="Add tower" (click)="showAddTower.set(true)" />
<img
class="add-tower"
src="assets/plus-sign.svg"
alt="Add tower"
role="button"
tabindex="0"
(click)="showAddTower.set(true)"
(keydown.enter)="showAddTower.set(true)"
(keydown.space)="$event.preventDefault(); showAddTower.set(true)"
/>
</div>
}
</section>
@ -57,7 +66,7 @@
<lt-modal (close)="cancelTowerDelete()">
<div class="confirm-delete">
<div class="header">
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
<button class="exit" type="button" (click)="cancelTowerDelete()" aria-label="Cancel"></button>
<h2>Delete tower</h2>
</div>
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>

View file

@ -1,4 +1,4 @@
@import '../../../styles';
@import '../../../library/main';
:host {
display: flex;

View file

@ -4,8 +4,11 @@ import {
input,
output,
signal,
computed,
inject,
HostListener,
effect,
untracked,
} from '@angular/core';
import { Page } from '../../models';
import { StoreService } from '../../services/store.service';
@ -16,7 +19,6 @@ import {
DoubleSliderComponent,
DoubleSliderRange,
} from '../shared/double-slider/double-slider.component';
import { computed } from '@angular/core';
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
import { ModalStateService } from '../../services/modal-state.service';
@ -116,6 +118,14 @@ export class PageComponent {
/** Selected date range — `null` = show everything. */
readonly dateRange = signal<{ from: number; to: number } | null>(null);
constructor() {
effect(() => {
if (!this.showSlider()) {
untracked(() => this.dateRange.set(null));
}
});
}
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
this.dateRange.set({ from: range.from as number, to: range.to as number });
}
@ -147,7 +157,7 @@ export class PageComponent {
}
onDeleteTower(towerId: string): void {
this.store.deleteTower(this.page().id, towerId);
this.confirmDeleteTowerId.set(towerId);
}
// ── Block mutations ────────────────────────────────────────────────────────

View file

@ -1,4 +1,4 @@
@import '../../../styles';
@import '../../../library/main';
:host {
height: 100%;

View file

@ -82,8 +82,6 @@ export class PagesComponent implements OnDestroy {
return pages[0] ?? null;
});
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
readonly confirmDeletePageName = computed(() => {
const id = this.confirmDeletePageId();
if (!id) return '';

View file

@ -32,7 +32,7 @@ export interface DoubleSliderRange<T> {
id="ds-1"
type="range"
min="0"
[max]="MAX - 1"
[max]="maxIndex()"
[value]="oneValue()"
(input)="oneValue.set(+$any($event.target).value)"
/>
@ -40,7 +40,7 @@ export interface DoubleSliderRange<T> {
id="ds-2"
type="range"
min="0"
[max]="MAX - 1"
[max]="maxIndex()"
[value]="otherValue()"
(input)="otherValue.set(+$any($event.target).value)"
/>
@ -168,10 +168,9 @@ export class DoubleSliderComponent {
readonly rangeChange = output<DoubleSliderRange<unknown>>();
readonly MAX = 100;
readonly oneValue = signal(0);
readonly otherValue = signal(this.MAX - 1);
readonly otherValue = signal(0);
readonly maxIndex = computed(() => Math.max(0, this.values().length - 1));
private prevValuesLength = 0;
@ -198,32 +197,33 @@ export class DoubleSliderComponent {
const hi = Math.max(a, b);
untracked(() => {
this.rangeChange.emit({
from: vs[this.indexFromValue(lo)],
to: vs[this.indexFromValue(hi)],
from: vs[this.clampIndex(lo)],
to: vs[this.clampIndex(hi)],
});
});
});
// Snap the higher thumb to MAX - 1 when a new entry is appended.
// Snap the higher thumb to the newest value when a new entry is appended.
effect(() => {
const len = this.values().length;
untracked(() => {
const max = Math.max(0, len - 1);
if (len > this.prevValuesLength) {
const a = this.oneValue();
const b = this.otherValue();
if (a > b) this.oneValue.set(this.MAX - 1);
else this.otherValue.set(this.MAX - 1);
if (a > b) this.oneValue.set(max);
else this.otherValue.set(max);
} else {
if (this.oneValue() > max) this.oneValue.set(max);
if (this.otherValue() > max) this.otherValue.set(max);
}
this.prevValuesLength = len;
});
});
}
private indexFromValue(value: number): number {
return Math.min(
this.values().length - 1,
Math.floor((value / this.MAX) * this.values().length),
);
private clampIndex(value: number): number {
return Math.max(0, Math.min(this.values().length - 1, Math.round(value)));
}
/**
@ -231,9 +231,10 @@ export class DoubleSliderComponent {
* upward and rotates -45° as a thumb approaches.
*/
getOffset(index: number): string {
const labelIndex = index / Math.max(1, this.drawnLabels().length);
const a = this.oneValue() / this.MAX - 0.1;
const b = this.otherValue() / this.MAX - 0.1;
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
const max = Math.max(1, this.maxIndex());
const a = this.oneValue() / max - 0.1;
const b = this.otherValue() / max - 0.1;
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
const ACTIVE_ZONE = 0.2;
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';

View file

@ -4,8 +4,6 @@ import {
input,
output,
signal,
OnChanges,
SimpleChanges,
ElementRef,
HostListener,
inject,
@ -22,7 +20,15 @@ import {
[class.always-shadow]="alwaysDropShadow()"
>
<div class="background" [class.active]="open()"></div>
<div class="top" (click)="toggleOpen($event)">
<div
class="top"
role="button"
tabindex="0"
[attr.aria-expanded]="open()"
(click)="toggleOpen($event)"
(keydown.enter)="toggleOpen($event)"
(keydown.space)="$event.preventDefault(); toggleOpen($event)"
>
<p>{{ resolvedSelected() ?? placeholder() }}</p>
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
</div>
@ -33,6 +39,7 @@ import {
<input
type="text"
[value]="item"
maxlength="200"
(blur)="onRename(item, $any($event.target).value)"
/>
} @else {
@ -46,6 +53,7 @@ import {
type="text"
#addInput
placeholder="Add a value…"
maxlength="200"
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
/>
<button
@ -365,7 +373,7 @@ import {
}
`,
})
export class SelectAddComponent implements OnChanges {
export class SelectAddComponent {
// ── New API (spec) ─────────────────────────────────────────────────────────
/** List of string options */
readonly items = input<string[]>([]);
@ -415,10 +423,6 @@ export class SelectAddComponent implements OnChanges {
return null;
}
ngOnChanges(_changes: SimpleChanges): void {
// Nothing to do — signals handle reactivity
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.open()) return;
@ -428,7 +432,7 @@ export class SelectAddComponent implements OnChanges {
}
}
toggleOpen(event: MouseEvent): void {
toggleOpen(event: Event): void {
event.stopPropagation();
this.open.update((v) => !v);
}

View file

@ -11,7 +11,14 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toggle">
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
<span
role="button"
tabindex="0"
[class.active]="!checked()"
(click)="set(false)"
(keydown.enter)="set(false)"
(keydown.space)="$event.preventDefault(); set(false)"
>{{ offLabel() }}</span>
<label>
<input
type="checkbox"
@ -20,7 +27,14 @@ import {
(change)="set(!checked())"
/>
</label>
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
<span
role="button"
tabindex="0"
[class.active]="checked()"
(click)="set(true)"
(keydown.enter)="set(true)"
(keydown.space)="$event.preventDefault(); set(true)"
>{{ onLabel() }}</span>
</div>
`,
styles: `

View file

@ -20,19 +20,20 @@ import { getColorOfTag } from '../../utils/color';
(pointerdown)="$event.stopPropagation()"
(mousedown)="$event.stopPropagation()"
(touchstart)="$event.stopPropagation()"
(pointerup)="toggleExpanded($event)"
(touchend)="toggleExpanded($event)"
(click)="toggleExpanded($event)"
>
<p
<button
type="button"
class="header"
(pointerup)="toggleExpanded($event)"
(touchend)="toggleExpanded($event)"
(click)="toggleExpanded($event)"
[attr.aria-expanded]="expanded()"
>
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
</p>
@if (pending().length === 0) {
<span aria-hidden="true">&nbsp;</span>
} @else {
{{ pending().length === 1 ? 'task' : 'tasks' }}
}
</button>
<div
#all
class="all-task"
@ -49,12 +50,12 @@ import { getColorOfTag } from '../../utils/color';
(click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button>
<p
<button
type="button"
class="task-description"
[style.color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</p>
>{{ b.description || b.tag }}</button>
</div>
}
</div>
@ -87,9 +88,19 @@ import { getColorOfTag } from '../../utils/color';
max-height: 30vh;
overflow-y: auto;
.header { cursor: pointer; }
.header {
all: unset;
@include medium-text();
display: block;
width: 100%;
box-sizing: border-box;
cursor: pointer;
text-align: center;
p { font-size: var(--medium-font-size); }
&::after {
content: none;
}
}
.all-task {
@include inner-spacing(var(--small-padding));
@ -124,7 +135,7 @@ import { getColorOfTag } from '../../utils/color';
gap: calc(var(--small-padding) / 2);
}
&:hover p {
&:hover .task-description {
@media (min-width: $mobile-width) {
color: inherit !important;
}
@ -187,7 +198,9 @@ import { getColorOfTag } from '../../utils/color';
}
}
p {
.task-description {
all: unset;
@include medium-text();
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
@ -203,6 +216,7 @@ import { getColorOfTag } from '../../utils/color';
}
position: relative;
&::after { content: none; }
}
}
}
@ -222,7 +236,6 @@ export class TasksComponent {
readonly edit = output<Block>();
readonly expanded = signal(false);
private lastToggleAt = 0;
constructor() {
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
@ -241,12 +254,6 @@ export class TasksComponent {
toggleExpanded(event: Event): void {
event.stopPropagation();
if (event.type === 'touchend') {
event.preventDefault();
}
const now = Date.now();
if (now - this.lastToggleAt < 250) return;
this.lastToggleAt = now;
this.expanded.update((v) => !v);
}
}

View file

@ -140,7 +140,6 @@ export function selectVisibleStyledBlocks(
<div
class="container"
[class.trash-highlight]="trashHighlight()"
>
<lt-tasks
[pending]="pending()"
@ -264,7 +263,7 @@ export function selectVisibleStyledBlocks(
transform: scale(0.75);
position: relative;
:before {
&::before {
opacity: 0.5 !important;
}
}
@ -314,7 +313,7 @@ export function selectVisibleStyledBlocks(
width: 100%;
:before {
&::before {
content: '';
pointer-events: none;
position: absolute;
@ -513,8 +512,6 @@ export function selectVisibleStyledBlocks(
export class TowerComponent implements AfterViewInit, OnDestroy {
// ── Inputs ─────────────────────────────────────────────────────────────────
readonly tower = input.required<Tower>();
/** Set by page component when this tower is being dragged over trash. */
readonly trashHighlight = input<boolean>(false);
/** Optional date range filter when set, blocks with `created_at`
* outside [from, to] are hidden from the falling stack. */
readonly dateRange = input<{ from: number; to: number } | null>(null);
@ -547,6 +544,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
private readonly maxVisibleBlocks = signal<number | null>(null);
private resizeObserver: ResizeObserver | null = null;
private destroyed = false;
private readonly animationFrames = new Set<number>();
// ── Derived ────────────────────────────────────────────────────────────────
/** Pending (not-done) blocks — fed to the tasks accordion. */
@ -638,6 +637,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
}
ngOnDestroy(): void {
this.destroyed = true;
for (const id of this.animationFrames) cancelAnimationFrame(id);
this.animationFrames.clear();
this.resizeObserver?.disconnect();
}
@ -788,25 +790,48 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
block._opacity = '0';
}
this._visibleBlocks.set(visibleStyled);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const block of blocks) {
block._anim = 'descend';
block._transform = 'translateY(0)';
block._opacity = '1';
}
this._visibleBlocks.set([...this._visibleBlocks()]);
this.requestFrame(() => {
this.requestFrame(() => {
if (this.destroyed) return;
this.finishDescendAnimation(blocks);
});
});
}
private requestFrame(callback: () => void): void {
if (typeof requestAnimationFrame !== 'function') {
callback();
return;
}
const id = requestAnimationFrame(() => {
this.animationFrames.delete(id);
if (!this.destroyed) callback();
});
this.animationFrames.add(id);
}
private finishDescendAnimation(blocks: StyledBlock[]): void {
for (const block of blocks) {
block._anim = 'descend';
block._transform = 'translateY(0)';
block._opacity = '1';
}
this._visibleBlocks.set([...this._visibleBlocks()]);
}
// ── Event handlers ─────────────────────────────────────────────────────────
onRename(event: Event): void {
const input = event.target as HTMLInputElement;
const newName = input.value.trim();
if (newName && newName !== this.tower().name) {
if (!newName) {
input.value = this.tower().name;
return;
}
if (newName !== this.tower().name) {
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
} else {
input.value = this.tower().name;
}
}