frontend(page): update page layout, slider and page selector

This commit is contained in:
Andras Schmelczer 2026-05-31 10:49:26 +01:00
parent 108cfb9a19
commit 8390ece334
6 changed files with 257 additions and 46 deletions

View file

@ -7,10 +7,11 @@
@for (tower of page().towers; track tower.id) {
<lt-tower
cdkDrag
[cdkDragDisabled]="modalOpen()"
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
[tower]="tower"
[dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open"
[animateInitialStack]="animateInitialStack()"
(cdkDragStarted)="onTowerDragStart(tower.id)"
(updateTower)="onUpdateTower(tower.id, $event)"
(deleteTowerRequest)="onDeleteTower(tower.id)"
@ -21,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>
@ -48,7 +58,7 @@
@if (showAddTower()) {
<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>
}
@ -56,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,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%; }
}
}
}

View file

@ -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<Page>();
readonly animateInitialStack = input<boolean>(false);
readonly dragHappening = output<boolean>();
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<unknown>): 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');
}
}

View file

@ -10,7 +10,11 @@
<div class="page-container">
@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 {
<p>Add a new page to get started!</p>
}
@ -27,14 +31,37 @@
[page]="selectedPage()"
(close)="showSettings.set(false)"
(updatePage)="onUpdatePage($event)"
(deletePage)="onRemovePage()"
(deletePage)="onRequestRemovePage()"
(switchAccount)="onSwitchAccount($event)"
/>
</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()) {
<lt-modal (close)="showWelcome.set(false)">
<lt-modal
labelledBy="welcome-title"
describedBy="welcome-description"
(close)="showWelcome.set(false)"
>
<lt-welcome
(close)="showWelcome.set(false)"
(startFresh)="showWelcome.set(false)"

View file

@ -1,7 +1,8 @@
@import '../../../styles';
@import '../../../library/main';
:host {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
@ -11,14 +12,24 @@
.select-add-container {
width: 250px;
margin-left: auto;
margin-right: auto;
margin: 0 auto;
position: relative;
z-index: 1000;
@media (max-width: $mobile-width) {
width: 80vw;
max-width: 320px;
}
}
.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 {
@ -27,5 +38,65 @@
&.transparent {
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,
computed,
effect,
OnDestroy,
} from '@angular/core';
import { StoreService } from '../../services/store.service';
import { PageComponent } from '../page/page.component';
@ -22,7 +23,7 @@ import { Page } from '../../models';
templateUrl: './pages.component.html',
styleUrl: './pages.component.scss',
})
export class PagesComponent {
export class PagesComponent implements OnDestroy {
protected readonly store = inject(StoreService);
/** ID of currently selected page within store.pages(). */
@ -31,22 +32,43 @@ export class PagesComponent {
readonly showSettings = signal(false);
readonly dragHappening = 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() {
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<Page | null>(() => {
@ -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 {