life-towers/frontend/src/app/components/page/page.component.ts

259 lines
9 KiB
TypeScript

import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
inject,
HostListener,
effect,
untracked,
ElementRef,
Injector,
afterNextRender,
} from '@angular/core';
import { Page } from '../../models';
import { StoreService } from '../../services/store.service';
import { TowerComponent } from '../tower/tower.component';
import { ModalComponent } from '../modal/modal.component';
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
import {
DoubleSliderComponent,
DoubleSliderRange,
} from '../shared/double-slider/double-slider.component';
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
import { ModalStateService } from '../../services/modal-state.service';
// ── Relative-time helpers ──────────────────────────────────────────────────
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' });
function formatRelative(ts: number, nowSec: number): string {
const diff = ts - nowSec; // negative = past
const absDiff = Math.abs(diff);
if (absDiff < 45) return rtf.format(Math.round(diff), 'second');
if (absDiff < 60 * 45) return rtf.format(Math.round(diff / 60), 'minute');
if (absDiff < 60 * 60 * 22) return rtf.format(Math.round(diff / 3600), 'hour');
if (absDiff < 86400 * 26) return rtf.format(Math.round(diff / 86400), 'day');
if (absDiff < 86400 * 320) return rtf.format(Math.round(diff / 86400 / 30), 'month');
return rtf.format(Math.round(diff / 86400 / 365), 'year');
}
interface BlockPatch {
tag: string;
description: string;
is_done: boolean;
difficulty: number;
}
/** Minimum blocks before the date-range slider becomes visible. */
const MIN_BLOCKS_FOR_SLIDER = 2;
@Component({
selector: 'lt-page',
standalone: true,
imports: [
TowerComponent,
ModalComponent,
TowerSettingsComponent,
DoubleSliderComponent,
CdkDropList,
CdkDrag,
],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './page.component.html',
styleUrl: './page.component.scss',
})
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);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
/** 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);
/** When set, opens a confirmation modal before the dragged tower is deleted. */
readonly confirmDeleteTowerId = signal<string | null>(null);
private draggedTowerId: string | null = null;
private nearTrashcan = false;
readonly confirmDeleteTowerName = computed(() => {
const id = this.confirmDeleteTowerId();
if (!id) return '';
return this.page().towers.find((t) => t.id === id)?.name ?? '';
});
// ── Date-range slider state ────────────────────────────────────────────────
/** Sorted unique `created_at` timestamps (seconds) across all done blocks
* in this page. Empty list when no blocks yet. */
readonly blockDates = computed<number[]>(() => {
const set = new Set<number>();
for (const tower of this.page().towers) {
for (const b of tower.blocks) if (b.is_done) set.add(b.created_at);
}
return [...set].sort((a, b) => a - b);
});
/** Date labels formatted for slider display (deduplicated, insertion order). */
readonly dateLabels = computed<string[]>(() => {
const now = Math.floor(Date.now() / 1000);
const seen = new Set<string>();
const labels: string[] = [];
for (const t of this.blockDates()) {
const lbl = formatRelative(t, now);
if (!seen.has(lbl)) {
seen.add(lbl);
labels.push(lbl);
}
}
return labels;
});
readonly showSlider = computed(() => this.blockDates().length >= MIN_BLOCKS_FOR_SLIDER);
/** 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 {
this.showAddTower.set(false);
const towerId = this.store.addTower(this.page().id, result.name, result.base_color);
this.centerTowerOnMobile(towerId);
}
/**
* On mobile the tower row is a horizontal scroll-snap container. Adding a
* tower appends it next to the (far-right) "+" button, so without this the
* view stays scrolled on the "+" button rather than the new tower. Center
* the freshly-created tower once it's painted. No-op on desktop, where the
* row is centered and doesn't scroll.
*/
private centerTowerOnMobile(towerId: string): void {
if (!this.isMobileViewport()) return;
afterNextRender(
() => {
const container = this.host.nativeElement.querySelector<HTMLElement>('.towers');
const tower = container?.querySelector<HTMLElement>(`[data-tower-id="${towerId}"]`);
if (!container || !tower) return;
const containerRect = container.getBoundingClientRect();
const towerRect = tower.getBoundingClientRect();
const delta =
towerRect.left + towerRect.width / 2 - (containerRect.left + containerRect.width / 2);
container.scrollTo({ left: container.scrollLeft + delta, behavior: 'smooth' });
},
{ injector: this.injector },
);
}
onUpdateTower(towerId: string, result: TowerSettingsResult): void {
this.store.updateTower(this.page().id, towerId, {
name: result.name,
base_color: result.base_color,
});
}
onDeleteTower(towerId: string): void {
this.confirmDeleteTowerId.set(towerId);
}
// ── Block mutations ────────────────────────────────────────────────────────
onAddBlock(towerId: string, result: BlockPatch): void {
this.store.addBlock(
this.page().id,
towerId,
result.tag,
result.description,
result.is_done,
result.difficulty,
);
}
onSaveBlock(towerId: string, event: { blockId: string; result: BlockPatch }): void {
this.store.updateBlock(this.page().id, towerId, event.blockId, event.result);
}
onDeleteBlock(towerId: string, blockId: string): void {
this.store.deleteBlock(this.page().id, towerId, blockId);
}
// ── Drag-and-drop + trash ──────────────────────────────────────────────────
onTowerDragStart(towerId: string): void {
this.draggedTowerId = towerId;
this.isDragging.set(true);
this.dragHappening.emit(true);
}
onTowerDropped(event: CdkDragDrop<unknown>): void {
this.isDragging.set(false);
this.dragHappening.emit(false);
if (this.nearTrashcan && this.draggedTowerId) {
// Open confirm dialog instead of deleting immediately.
this.confirmDeleteTowerId.set(this.draggedTowerId);
} else if (event.previousIndex !== event.currentIndex) {
this.store.reorderTowers(this.page().id, event.previousIndex, event.currentIndex);
}
this.draggedTowerId = null;
this.nearTrashcan = false;
}
confirmTowerDelete(): void {
const id = this.confirmDeleteTowerId();
if (id) this.store.deleteTower(this.page().id, id);
this.confirmDeleteTowerId.set(null);
}
cancelTowerDelete(): void {
this.confirmDeleteTowerId.set(null);
}
onTrashEnter(): void {
this.nearTrashcan = true;
this.dragPreview()?.classList.add('trash-highlight');
}
onTrashLeave(): void {
this.nearTrashcan = false;
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');
}
}