snapshot
This commit is contained in:
parent
3ad2766f82
commit
f74ee43cb4
196 changed files with 18949 additions and 32173 deletions
69
frontend/src/app/components/page/page.component.html
Normal file
69
frontend/src/app/components/page/page.component.html
Normal 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>
|
||||
}
|
||||
148
frontend/src/app/components/page/page.component.scss
Normal file
148
frontend/src/app/components/page/page.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
199
frontend/src/app/components/page/page.component.ts
Normal file
199
frontend/src/app/components/page/page.component.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue