Modernise & deploy #1

Merged
andras merged 25 commits from asch/modernise into master 2026-05-31 16:48:45 +01:00
6 changed files with 257 additions and 46 deletions
Showing only changes of commit 8390ece334 - Show all commits

View file

@ -7,10 +7,11 @@
@for (tower of page().towers; track tower.id) { @for (tower of page().towers; track tower.id) {
<lt-tower <lt-tower
cdkDrag cdkDrag
[cdkDragDisabled]="modalOpen()" [cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
[tower]="tower" [tower]="tower"
[dateRange]="dateRange()" [dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open" [keepTasksOpen]="page().keep_tasks_open"
[animateInitialStack]="animateInitialStack()"
(cdkDragStarted)="onTowerDragStart(tower.id)" (cdkDragStarted)="onTowerDragStart(tower.id)"
(updateTower)="onUpdateTower(tower.id, $event)" (updateTower)="onUpdateTower(tower.id, $event)"
(deleteTowerRequest)="onDeleteTower(tower.id)" (deleteTowerRequest)="onDeleteTower(tower.id)"
@ -21,7 +22,16 @@
} }
@if (!page().hide_create_tower_button) { @if (!page().hide_create_tower_button) {
<div class="add-tower-wrapper"> <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> </div>
} }
</section> </section>
@ -48,7 +58,7 @@
@if (showAddTower()) { @if (showAddTower()) {
<lt-modal title="New Tower" (close)="showAddTower.set(false)"> <lt-modal title="New Tower" (close)="showAddTower.set(false)">
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" /> <lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
</lt-modal> </lt-modal>
} }
@ -56,7 +66,7 @@
<lt-modal (close)="cancelTowerDelete()"> <lt-modal (close)="cancelTowerDelete()">
<div class="confirm-delete"> <div class="confirm-delete">
<div class="header"> <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> <h2>Delete tower</h2>
</div> </div>
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p> <p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>

View file

@ -1,10 +1,11 @@
@import '../../../styles'; @import '../../../library/main';
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0;
position: relative; // anchor for absolute-positioned .trash position: relative; // anchor for absolute-positioned .trash
@include inner-spacing(var(--large-padding)); @include inner-spacing(var(--large-padding));
@ -18,14 +19,17 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
box-sizing: border-box;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
flex: 1 0 auto; flex: 1 1 auto;
min-height: 0;
transition: box-shadow $short-animation-time; transition: box-shadow $short-animation-time;
max-width: 800px; max-width: 100%;
gap: var(--medium-padding);
&.cdk-drop-list-dragging { &.cdk-drop-list-dragging {
*:not(.cdk-drag-placeholder) { *:not(.cdk-drag-placeholder) {
@ -52,28 +56,45 @@
} }
& > * { & > * {
width: 100%;
max-width: 200px; max-width: 200px;
box-sizing: content-box; box-sizing: border-box;
flex: 0 0 auto; flex: 1 1 0;
min-width: 0;
&:not(:nth-last-child(1)) { min-height: 0;
margin-right: var(--medium-padding);
@media (max-width: $mobile-width) {
margin-right: var(--small-padding);
}
}
} }
position: relative; position: relative;
@for $i from 1 to 12 { // Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
& > *:first-child:nth-last-child(#{$i}), @media (max-width: $mobile-width) {
& > *:first-child:nth-last-child(#{$i}) ~ * { --mobile-tower-width: calc(66vw - var(--small-padding));
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i}); --mobile-tower-side-padding: max(
var(--medium-padding),
calc((100% - var(--mobile-tower-width)) / 2)
);
@media (max-width: $mobile-width) { overflow-x: auto;
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i}); overflow-y: visible;
} -webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--mobile-tower-side-padding);
flex-wrap: nowrap;
justify-content: flex-start;
padding: 0 var(--mobile-tower-side-padding);
max-width: 100%;
gap: var(--medium-padding);
&::-webkit-scrollbar {
display: none;
}
& > * {
width: var(--mobile-tower-width) !important;
max-width: var(--mobile-tower-width) !important;
min-width: var(--mobile-tower-width) !important;
scroll-snap-align: center;
flex: 0 0 var(--mobile-tower-width);
} }
} }
} }
@ -89,7 +110,11 @@
@include card(); @include card();
width: 66vw; width: 66vw;
max-width: 500px; max-width: 500px;
@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; box-sizing: border-box;
padding: var(--large-padding); padding: var(--large-padding);
position: relative; position: relative;
@ -101,7 +126,7 @@
@include center-child(); @include center-child();
.exit { .exit {
position: absolute; position: absolute;
left: var(--large-padding); right: var(--large-padding);
@include exit(); @include exit();
} }
} }
@ -115,11 +140,21 @@
justify-content: center; justify-content: center;
gap: var(--large-padding); gap: var(--large-padding);
button {
max-width: 100%;
}
button.danger { button.danger {
color: #b53f3f; color: #b53f3f;
border-bottom-color: #b53f3f55; border-bottom-color: #b53f3f55;
&:after { background-color: #b53f3f; } &:after { background-color: #b53f3f; }
} }
@media (max-width: $mobile-width) {
flex-direction: column;
gap: var(--small-padding);
button { width: 100%; }
}
} }
} }

View file

@ -4,7 +4,11 @@ import {
input, input,
output, output,
signal, signal,
computed,
inject, inject,
HostListener,
effect,
untracked,
} from '@angular/core'; } from '@angular/core';
import { Page } from '../../models'; import { Page } from '../../models';
import { StoreService } from '../../services/store.service'; import { StoreService } from '../../services/store.service';
@ -15,7 +19,6 @@ import {
DoubleSliderComponent, DoubleSliderComponent,
DoubleSliderRange, DoubleSliderRange,
} from '../shared/double-slider/double-slider.component'; } from '../shared/double-slider/double-slider.component';
import { computed } from '@angular/core';
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop'; import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
import { ModalStateService } from '../../services/modal-state.service'; import { ModalStateService } from '../../services/modal-state.service';
@ -38,6 +41,7 @@ interface BlockPatch {
tag: string; tag: string;
description: string; description: string;
is_done: boolean; is_done: boolean;
difficulty: number;
} }
/** Minimum blocks before the date-range slider becomes visible. */ /** Minimum blocks before the date-range slider becomes visible. */
@ -60,12 +64,14 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
}) })
export class PageComponent { export class PageComponent {
readonly page = input.required<Page>(); readonly page = input.required<Page>();
readonly animateInitialStack = input<boolean>(false);
readonly dragHappening = output<boolean>(); readonly dragHappening = output<boolean>();
protected readonly store = inject(StoreService); protected readonly store = inject(StoreService);
private readonly modalState = inject(ModalStateService); private readonly modalState = inject(ModalStateService);
/** True while any lt-modal is mounted — used to lock tower drag. */ /** True while any lt-modal is mounted — used to lock tower drag. */
readonly modalOpen = this.modalState.anyOpen; readonly modalOpen = this.modalState.anyOpen;
readonly mobileDragDisabled = signal(this.isMobileViewport());
readonly showAddTower = signal(false); readonly showAddTower = signal(false);
readonly isDragging = signal(false); readonly isDragging = signal(false);
@ -112,10 +118,30 @@ export class PageComponent {
/** Selected date range — `null` = show everything. */ /** Selected date range — `null` = show everything. */
readonly dateRange = signal<{ from: number; to: number } | null>(null); readonly dateRange = signal<{ from: number; to: number } | null>(null);
constructor() {
effect(() => {
if (!this.showSlider()) {
untracked(() => this.dateRange.set(null));
}
});
}
onSliderRangeChange(range: DoubleSliderRange<unknown>): void { onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
this.dateRange.set({ from: range.from as number, to: range.to as number }); this.dateRange.set({ from: range.from as number, to: range.to as number });
} }
@HostListener('window:resize')
onResize(): void {
this.mobileDragDisabled.set(this.isMobileViewport());
}
private isMobileViewport(): boolean {
return (
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 520px), (pointer: coarse)').matches
);
}
// ── Tower mutations ──────────────────────────────────────────────────────── // ── Tower mutations ────────────────────────────────────────────────────────
onAddTower(result: TowerSettingsResult): void { onAddTower(result: TowerSettingsResult): void {
@ -131,7 +157,7 @@ export class PageComponent {
} }
onDeleteTower(towerId: string): void { onDeleteTower(towerId: string): void {
this.store.deleteTower(this.page().id, towerId); this.confirmDeleteTowerId.set(towerId);
} }
// ── Block mutations ──────────────────────────────────────────────────────── // ── Block mutations ────────────────────────────────────────────────────────
@ -143,6 +169,7 @@ export class PageComponent {
result.tag, result.tag,
result.description, result.description,
result.is_done, result.is_done,
result.difficulty,
); );
} }
@ -187,13 +214,16 @@ export class PageComponent {
onTrashEnter(): void { onTrashEnter(): void {
this.nearTrashcan = true; this.nearTrashcan = true;
const preview = document.querySelector('.cdk-drag-preview'); this.dragPreview()?.classList.add('trash-highlight');
if (preview) preview.classList.add('trash-highlight');
} }
onTrashLeave(): void { onTrashLeave(): void {
this.nearTrashcan = false; this.nearTrashcan = false;
const preview = document.querySelector('.cdk-drag-preview'); this.dragPreview()?.classList.remove('trash-highlight');
if (preview) preview.classList.remove('trash-highlight'); }
/** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */
private dragPreview(): Element | null {
return document.querySelector('.cdk-drag-preview');
} }
} }

View file

@ -10,7 +10,11 @@
<div class="page-container"> <div class="page-container">
@if (selectedPage(); as page) { @if (selectedPage(); as page) {
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" /> <lt-page
[page]="page"
[animateInitialStack]="page.id === animateInitialStackPageId()"
(dragHappening)="dragHappening.set($event)"
/>
} @else { } @else {
<p>Add a new page to get started!</p> <p>Add a new page to get started!</p>
} }
@ -27,14 +31,37 @@
[page]="selectedPage()" [page]="selectedPage()"
(close)="showSettings.set(false)" (close)="showSettings.set(false)"
(updatePage)="onUpdatePage($event)" (updatePage)="onUpdatePage($event)"
(deletePage)="onRemovePage()" (deletePage)="onRequestRemovePage()"
(switchAccount)="onSwitchAccount($event)" (switchAccount)="onSwitchAccount($event)"
/> />
</lt-modal> </lt-modal>
} }
@if (confirmDeletePageId()) {
<lt-modal (close)="cancelRemovePage()">
<div class="confirm-delete">
<div class="header">
<button class="exit" type="button" (click)="cancelRemovePage()" aria-label="Cancel"></button>
<h2>Delete page</h2>
</div>
<p>
Delete <strong>{{ confirmDeletePageName() || 'this page' }}</strong> and all of its towers and blocks?
This can't be undone.
</p>
<div class="confirm-buttons">
<button type="button" (click)="cancelRemovePage()">Cancel</button>
<button type="button" class="danger" (click)="confirmRemovePage()">Delete page</button>
</div>
</div>
</lt-modal>
}
@if (showWelcome()) { @if (showWelcome()) {
<lt-modal (close)="showWelcome.set(false)"> <lt-modal
labelledBy="welcome-title"
describedBy="welcome-description"
(close)="showWelcome.set(false)"
>
<lt-welcome <lt-welcome
(close)="showWelcome.set(false)" (close)="showWelcome.set(false)"
(startFresh)="showWelcome.set(false)" (startFresh)="showWelcome.set(false)"

View file

@ -1,7 +1,8 @@
@import '../../../styles'; @import '../../../library/main';
:host { :host {
height: 100%; height: 100%;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -11,14 +12,24 @@
.select-add-container { .select-add-container {
width: 250px; width: 250px;
margin-left: auto; margin: 0 auto;
margin-right: auto;
position: relative; position: relative;
z-index: 1000; z-index: 1000;
@media (max-width: $mobile-width) {
width: 80vw;
max-width: 320px;
}
} }
.page-container { .page-container {
flex: 1 0 auto; flex: 1 1 auto;
min-height: 0;
// Generous breathing room between the page selector dropdown and the
// towers the dropdown can open downward without crowding the towers.
padding-top: var(--large-padding);
@media (max-width: $mobile-width) {
padding-top: var(--medium-padding);
}
} }
button { button {
@ -27,5 +38,65 @@
&.transparent { &.transparent {
opacity: 0; opacity: 0;
} }
@media (max-width: $mobile-width) {
margin-top: var(--medium-padding);
font-size: var(--medium-font-size);
}
}
.confirm-delete {
@include card();
width: 66vw;
max-width: 500px;
@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));
text-align: center;
.header {
@include center-child();
.exit {
position: absolute;
right: var(--large-padding);
@include exit();
}
}
.confirm-buttons {
display: flex;
justify-content: center;
gap: var(--large-padding);
button {
max-width: 100%;
}
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after {
background-color: #b53f3f;
}
}
@media (max-width: $mobile-width) {
flex-direction: column;
gap: var(--small-padding);
button {
width: 100%;
}
}
}
} }
} }

View file

@ -5,6 +5,7 @@ import {
signal, signal,
computed, computed,
effect, effect,
OnDestroy,
} from '@angular/core'; } from '@angular/core';
import { StoreService } from '../../services/store.service'; import { StoreService } from '../../services/store.service';
import { PageComponent } from '../page/page.component'; import { PageComponent } from '../page/page.component';
@ -22,7 +23,7 @@ import { Page } from '../../models';
templateUrl: './pages.component.html', templateUrl: './pages.component.html',
styleUrl: './pages.component.scss', styleUrl: './pages.component.scss',
}) })
export class PagesComponent { export class PagesComponent implements OnDestroy {
protected readonly store = inject(StoreService); protected readonly store = inject(StoreService);
/** ID of currently selected page within store.pages(). */ /** ID of currently selected page within store.pages(). */
@ -31,22 +32,43 @@ export class PagesComponent {
readonly showSettings = signal(false); readonly showSettings = signal(false);
readonly dragHappening = signal(false); readonly dragHappening = signal(false);
readonly showWelcome = signal(false); readonly showWelcome = signal(false);
readonly confirmDeletePageId = signal<string | null>(null);
readonly animateInitialStackPageId = signal<string | null>(null);
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
effect(() => { effect(() => {
if (!this.store.loading() && this.store.pages().length === 0) { const pages = this.store.pages();
if (!this.store.loading() && pages.length === 0) {
this.showWelcome.set(true); this.showWelcome.set(true);
} else if (this.store.pages().length > 0) { } else if (pages.length > 0) {
this.showWelcome.set(false); this.showWelcome.set(false);
} }
}); });
} }
onLoadExample(): void { onLoadExample(): void {
this.store.loadExample(); const pageId = this.store.loadExample();
this.selectedPageId.set(pageId);
this.animateInitialStackPageId.set(pageId);
if (this.exampleAnimationTimer !== null) {
clearTimeout(this.exampleAnimationTimer);
}
this.exampleAnimationTimer = setTimeout(() => {
if (this.animateInitialStackPageId() === pageId) {
this.animateInitialStackPageId.set(null);
}
this.exampleAnimationTimer = null;
}, 2500);
this.showWelcome.set(false); this.showWelcome.set(false);
} }
ngOnDestroy(): void {
if (this.exampleAnimationTimer !== null) {
clearTimeout(this.exampleAnimationTimer);
}
}
readonly pageNames = computed(() => this.store.pages().map((p) => p.name)); readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
readonly selectedPage = computed<Page | null>(() => { readonly selectedPage = computed<Page | null>(() => {
@ -61,12 +83,17 @@ export class PagesComponent {
return pages[0] ?? null; return pages[0] ?? null;
}); });
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null); readonly confirmDeletePageName = computed(() => {
const id = this.confirmDeletePageId();
if (!id) return '';
return this.store.pages().find((p) => p.id === id)?.name ?? '';
});
readonly selectedPageIndex = computed(() => { readonly selectedPageIndex = computed(() => {
const pages = this.store.pages();
const page = this.selectedPage(); const page = this.selectedPage();
if (!page) return -1; if (!page) return -1;
return this.store.pages().findIndex((p) => p.id === page.id); return pages.findIndex((p) => p.id === page.id);
}); });
onSelectPage(index: number): void { onSelectPage(index: number): void {
@ -94,12 +121,23 @@ export class PagesComponent {
} }
} }
onRemovePage(): void { onRequestRemovePage(): void {
const page = this.selectedPage(); const page = this.selectedPage();
if (!page) return; if (!page) return;
this.store.deletePage(page.id); this.confirmDeletePageId.set(page.id);
}
confirmRemovePage(): void {
const pageId = this.confirmDeletePageId();
if (!pageId) return;
this.store.deletePage(pageId);
this.selectedPageId.set(null); this.selectedPageId.set(null);
this.showSettings.set(false); this.showSettings.set(false);
this.confirmDeletePageId.set(null);
}
cancelRemovePage(): void {
this.confirmDeletePageId.set(null);
} }
onSwitchAccount(token: string): void { onSwitchAccount(token: string): void {