This commit is contained in:
Andras Schmelczer 2026-05-28 21:24:47 +01:00
parent 3ad2766f82
commit f74ee43cb4
196 changed files with 18949 additions and 32173 deletions

View file

@ -0,0 +1,69 @@
<section
class="towers"
cdkDropList
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="onTowerDropped($event)"
>
@for (tower of page().towers; track tower.id) {
<lt-tower
cdkDrag
[cdkDragDisabled]="modalOpen()"
[tower]="tower"
[dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open"
(cdkDragStarted)="onTowerDragStart(tower.id)"
(updateTower)="onUpdateTower(tower.id, $event)"
(deleteTowerRequest)="onDeleteTower(tower.id)"
(saveBlock)="onSaveBlock(tower.id, $event)"
(addBlock)="onAddBlock(tower.id, $event)"
(deleteBlock)="onDeleteBlock(tower.id, $event)"
/>
}
@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)" />
</div>
}
</section>
<!-- Trash zone is positioned relative to :host, not .towers — matches legacy. -->
<img
class="trash"
src="assets/trash.svg"
alt="Delete tower"
[class.active]="isDragging()"
(pointerenter)="onTrashEnter()"
(pointerleave)="onTrashLeave()"
/>
@if (showSlider()) {
<div class="double-slider-container" [class.transparent]="isDragging()">
<lt-double-slider
[values]="blockDates()"
[labels]="dateLabels()"
(rangeChange)="onSliderRangeChange($event)"
/>
</div>
}
@if (showAddTower()) {
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" />
</lt-modal>
}
@if (confirmDeleteTowerId(); as towerId) {
<lt-modal (close)="cancelTowerDelete()">
<div class="confirm-delete">
<div class="header">
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
<h2>Delete tower</h2>
</div>
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
<div class="confirm-buttons">
<button type="button" (click)="cancelTowerDelete()">Cancel</button>
<button type="button" class="danger" (click)="confirmTowerDelete()">Delete tower</button>
</div>
</div>
</lt-modal>
}

View file

@ -0,0 +1,148 @@
@import '../../../styles';
:host {
display: flex;
flex-direction: column;
height: 100%;
position: relative; // anchor for absolute-positioned .trash
@include inner-spacing(var(--large-padding));
button {
margin-top: 0;
}
.towers {
display: flex;
justify-content: center;
width: 100%;
margin-left: auto;
margin-right: auto;
flex: 1 0 auto;
transition: box-shadow $short-animation-time;
max-width: 800px;
&.cdk-drop-list-dragging {
*:not(.cdk-drag-placeholder) {
transition: transform $long-animation-time cubic-bezier(0, 0, 0.2, 1);
}
}
.add-tower-wrapper {
@include center-child();
img.add-tower {
height: 48px;
@media (max-width: $mobile-width) {
height: 32px;
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
& > * {
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);
}
}
}
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});
@media (max-width: $mobile-width) {
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
}
}
}
}
.double-slider-container {
width: 100%;
transition: opacity $long-animation-time;
@media (max-height: $min-height) { display: none; }
&.transparent { opacity: 0; pointer-events: none; }
}
.confirm-delete {
@include card();
width: 66vw;
max-width: 500px;
@media (max-width: $mobile-width) { width: 300px; }
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;
left: var(--large-padding);
@include exit();
}
}
p {
strong { font-weight: bold; }
}
.confirm-buttons {
display: flex;
justify-content: center;
gap: var(--large-padding);
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after { background-color: #b53f3f; }
}
}
}
img.trash {
@include square(48px);
padding: 16px;
position: absolute;
z-index: 1500;
bottom: 8px;
left: 50%;
margin: 0 !important;
transform: translateX(-50%) scale(0);
transition: transform $long-animation-time;
&.active {
transform: translateX(-50%) scale(1);
}
&:hover {
transform: translateX(-50%) scale(1.1);
}
}
}

View file

@ -0,0 +1,199 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
inject,
} 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 { computed } from '@angular/core';
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;
}
/** 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 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 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);
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
this.dateRange.set({ from: range.from as number, to: range.to as number });
}
// ── Tower mutations ────────────────────────────────────────────────────────
onAddTower(result: TowerSettingsResult): void {
this.showAddTower.set(false);
this.store.addTower(this.page().id, result.name, result.base_color);
}
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.store.deleteTower(this.page().id, towerId);
}
// ── Block mutations ────────────────────────────────────────────────────────
onAddBlock(towerId: string, result: BlockPatch): void {
this.store.addBlock(
this.page().id,
towerId,
result.tag,
result.description,
result.is_done,
);
}
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;
const preview = document.querySelector('.cdk-drag-preview');
if (preview) preview.classList.add('trash-highlight');
}
onTrashLeave(): void {
this.nearTrashcan = false;
const preview = document.querySelector('.cdk-drag-preview');
if (preview) preview.classList.remove('trash-highlight');
}
}