From 8390ece3349f691bc7a09fcdac40e28cd60c9455 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 31 May 2026 10:49:26 +0100 Subject: [PATCH] frontend(page): update page layout, slider and page selector --- .../app/components/page/page.component.html | 18 ++++- .../app/components/page/page.component.scss | 77 +++++++++++++----- .../src/app/components/page/page.component.ts | 42 ++++++++-- .../app/components/pages/pages.component.html | 33 +++++++- .../app/components/pages/pages.component.scss | 79 ++++++++++++++++++- .../app/components/pages/pages.component.ts | 54 +++++++++++-- 6 files changed, 257 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/components/page/page.component.html b/frontend/src/app/components/page/page.component.html index 34d0292..431d960 100644 --- a/frontend/src/app/components/page/page.component.html +++ b/frontend/src/app/components/page/page.component.html @@ -7,10 +7,11 @@ @for (tower of page().towers; track tower.id) { - Add tower + Add tower } @@ -48,7 +58,7 @@ @if (showAddTower()) { - + } @@ -56,7 +66,7 @@
-
+

Delete tower

Delete {{ confirmDeleteTowerName() || 'this tower' }} and all of its blocks? This can't be undone.

diff --git a/frontend/src/app/components/page/page.component.scss b/frontend/src/app/components/page/page.component.scss index 6aa6a0c..ed8aa0c 100644 --- a/frontend/src/app/components/page/page.component.scss +++ b/frontend/src/app/components/page/page.component.scss @@ -1,10 +1,11 @@ -@import '../../../styles'; +@import '../../../library/main'; :host { display: flex; flex-direction: column; height: 100%; + min-height: 0; position: relative; // anchor for absolute-positioned .trash @include inner-spacing(var(--large-padding)); @@ -18,14 +19,17 @@ justify-content: center; width: 100%; + box-sizing: border-box; margin-left: auto; margin-right: auto; - flex: 1 0 auto; + flex: 1 1 auto; + min-height: 0; transition: box-shadow $short-animation-time; - max-width: 800px; + max-width: 100%; + gap: var(--medium-padding); &.cdk-drop-list-dragging { *:not(.cdk-drag-placeholder) { @@ -52,28 +56,45 @@ } & > * { + width: 100%; max-width: 200px; - box-sizing: content-box; - flex: 0 0 auto; - - &:not(:nth-last-child(1)) { - margin-right: var(--medium-padding); - @media (max-width: $mobile-width) { - margin-right: var(--small-padding); - } - } + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + min-height: 0; } position: relative; - @for $i from 1 to 12 { - & > *:first-child:nth-last-child(#{$i}), - & > *:first-child:nth-last-child(#{$i}) ~ * { - width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i}); + // Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm). + @media (max-width: $mobile-width) { + --mobile-tower-width: calc(66vw - var(--small-padding)); + --mobile-tower-side-padding: max( + var(--medium-padding), + calc((100% - var(--mobile-tower-width)) / 2) + ); - @media (max-width: $mobile-width) { - width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i}); - } + overflow-x: auto; + 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(); width: 66vw; 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; padding: var(--large-padding); position: relative; @@ -101,7 +126,7 @@ @include center-child(); .exit { position: absolute; - left: var(--large-padding); + right: var(--large-padding); @include exit(); } } @@ -115,11 +140,21 @@ 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%; } + } } } diff --git a/frontend/src/app/components/page/page.component.ts b/frontend/src/app/components/page/page.component.ts index 8e152a1..544ed13 100644 --- a/frontend/src/app/components/page/page.component.ts +++ b/frontend/src/app/components/page/page.component.ts @@ -4,7 +4,11 @@ import { input, output, signal, + computed, inject, + HostListener, + effect, + untracked, } from '@angular/core'; import { Page } from '../../models'; import { StoreService } from '../../services/store.service'; @@ -15,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'; @@ -38,6 +41,7 @@ interface BlockPatch { tag: string; description: string; is_done: boolean; + difficulty: number; } /** Minimum blocks before the date-range slider becomes visible. */ @@ -60,12 +64,14 @@ const MIN_BLOCKS_FOR_SLIDER = 2; }) export class PageComponent { readonly page = input.required(); + readonly animateInitialStack = input(false); readonly dragHappening = output(); protected readonly store = inject(StoreService); private readonly modalState = inject(ModalStateService); /** True while any lt-modal is mounted — used to lock tower drag. */ readonly modalOpen = this.modalState.anyOpen; + readonly mobileDragDisabled = signal(this.isMobileViewport()); readonly showAddTower = signal(false); readonly isDragging = signal(false); @@ -112,10 +118,30 @@ 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): void { 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 ──────────────────────────────────────────────────────── onAddTower(result: TowerSettingsResult): void { @@ -131,7 +157,7 @@ export class PageComponent { } onDeleteTower(towerId: string): void { - this.store.deleteTower(this.page().id, towerId); + this.confirmDeleteTowerId.set(towerId); } // ── Block mutations ──────────────────────────────────────────────────────── @@ -143,6 +169,7 @@ export class PageComponent { result.tag, result.description, result.is_done, + result.difficulty, ); } @@ -187,13 +214,16 @@ export class PageComponent { onTrashEnter(): void { this.nearTrashcan = true; - const preview = document.querySelector('.cdk-drag-preview'); - if (preview) preview.classList.add('trash-highlight'); + this.dragPreview()?.classList.add('trash-highlight'); } onTrashLeave(): void { this.nearTrashcan = false; - const preview = document.querySelector('.cdk-drag-preview'); - if (preview) preview.classList.remove('trash-highlight'); + this.dragPreview()?.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'); } } diff --git a/frontend/src/app/components/pages/pages.component.html b/frontend/src/app/components/pages/pages.component.html index 44dbccf..9cbafba 100644 --- a/frontend/src/app/components/pages/pages.component.html +++ b/frontend/src/app/components/pages/pages.component.html @@ -10,7 +10,11 @@
@if (selectedPage(); as page) { - + } @else {

Add a new page to get started!

} @@ -27,14 +31,37 @@ [page]="selectedPage()" (close)="showSettings.set(false)" (updatePage)="onUpdatePage($event)" - (deletePage)="onRemovePage()" + (deletePage)="onRequestRemovePage()" (switchAccount)="onSwitchAccount($event)" /> } +@if (confirmDeletePageId()) { + +
+
+ +

Delete page

+
+

+ Delete {{ confirmDeletePageName() || 'this page' }} and all of its towers and blocks? + This can't be undone. +

+
+ + +
+
+
+} + @if (showWelcome()) { - + (null); + readonly animateInitialStackPageId = signal(null); + private exampleAnimationTimer: ReturnType | null = null; constructor() { 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); - } else if (this.store.pages().length > 0) { + } else if (pages.length > 0) { this.showWelcome.set(false); } }); } 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); } + ngOnDestroy(): void { + if (this.exampleAnimationTimer !== null) { + clearTimeout(this.exampleAnimationTimer); + } + } + readonly pageNames = computed(() => this.store.pages().map((p) => p.name)); readonly selectedPage = computed(() => { @@ -61,12 +83,17 @@ export class PagesComponent { 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(() => { + const pages = this.store.pages(); const page = this.selectedPage(); 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 { @@ -94,12 +121,23 @@ export class PagesComponent { } } - onRemovePage(): void { + onRequestRemovePage(): void { const page = this.selectedPage(); 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.showSettings.set(false); + this.confirmDeletePageId.set(null); + } + + cancelRemovePage(): void { + this.confirmDeletePageId.set(null); } onSwitchAccount(token: string): void {