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,43 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color';
/**
* A block rendered as a small COLORED SQUARE (1/6 of tower width).
* Only DONE blocks appear here; pending blocks appear in the tasks accordion.
* Clicking opens the block-edit modal in the parent tower component.
*/
@Component({
selector: 'lt-block',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div [style.background-color]="color()" (click)="clicked.emit()"></div>
`,
styles: `
@import '../../../library/main';
:host {
position: relative;
width: calc(100% / 6);
padding-bottom: calc(100% / 6);
div {
position: absolute;
width: 100%;
height: 100%;
@include gravitate();
}
}
`,
})
export class BlockComponent {
readonly block = input.required<Block>();
readonly baseColor = input.required<HslColor>();
/** Emits when the square is clicked — parent opens the block-edit modal. */
readonly clicked = output<void>();
readonly color = computed(() => getColorOfTag(this.block().tag, this.baseColor()));
}

View file

@ -0,0 +1,521 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
inject,
signal,
computed,
effect,
viewChild,
ElementRef,
AfterViewInit,
HostListener,
untracked,
} from '@angular/core';
import { Block, HslColor } from '../../models';
import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { ToggleComponent } from '../shared/toggle/toggle.component';
import { getColorOfTag } from '../../utils/color';
export interface BlockEditSave {
/** null = create a new block */
id: string | null;
tag: string;
description: string;
is_done: boolean;
}
interface EditedValue {
tag: string;
description: string;
is_done: boolean;
}
@Component({
selector: 'lt-block-edit',
standalone: true,
imports: [SelectAddComponent, ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section
#container
class="carousel"
(scroll)="onScroll()"
(click)="onBackdropClick($event)"
>
<div class="card placeholder"></div>
@for (b of blocks(); track b.id; let i = $index) {
<div
class="card"
[class.active]="activeIdx() === i + 1"
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
(click)="onCardClick(i + 1)"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
<div
class="block-dot"
[style.background-color]="colorOfTagForBlock(b.id)"
></div>
<h1>{{ formatDate(b.created_at) }}</h1>
</div>
<div class="select-add-container">
<lt-select-add
[items]="tags()"
[selected]="editedFor(b.id).tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
placeholder="Tag this item…"
(select)="updateTag(b.id, $event)"
(add)="updateTag(b.id, $event)"
/>
</div>
<textarea
placeholder="Write a description here…"
[value]="editedFor(b.id).description"
(input)="updateDescription(b.id, $any($event.target).value)"
(blur)="flushExisting(b.id)"
></textarea>
<div>
<lt-toggle
[checked]="editedFor(b.id).is_done"
(checkedChange)="updateDone(b.id, $event)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
/>
</div>
<div class="bottom">
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
</div>
</div>
}
<div
class="card create-card"
[class.active]="activeIdx() === blocks().length + 1"
[class.near-active]="activeIdx() === blocks().length"
(click)="onCardClick(blocks().length + 1)"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
<div
class="block-dot"
[style.background-color]="colorOfNewTag()"
></div>
<h1>Create now</h1>
</div>
<div class="select-add-container">
<lt-select-add
[items]="tags()"
[selected]="newValue().tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
placeholder="Set a category…"
(select)="updateNewTag($event)"
(add)="updateNewTag($event)"
/>
</div>
<textarea
placeholder="Write a description here…"
[value]="newValue().description"
(input)="updateNewDescription($any($event.target).value)"
></textarea>
<div>
<lt-toggle
[checked]="newValue().is_done"
(checkedChange)="updateNewDone($event)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
/>
</div>
<div class="bottom">
<button
(click)="submitNew(); $event.stopPropagation()"
[disabled]="!newValue().tag"
>
Create and exit
</button>
</div>
</div>
<div class="card placeholder"></div>
</section>
`,
styles: `
@import '../../../library/main';
:host {
@include center-child();
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10001; // above modal backdrop (10000)
}
.carousel {
width: 100%;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.card {
@include card();
box-shadow: $shadow;
display: block;
transform-origin: center center;
flex: 0 0 auto;
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) {
width: 300px;
opacity: 1 !important;
}
box-sizing: border-box;
padding: var(--large-padding);
margin: calc(var(--large-padding) / 2);
position: relative;
@include inner-spacing(var(--large-padding));
opacity: 0.6;
transition: opacity $long-animation-time;
&.near-active {
cursor: pointer;
opacity: 0.85;
}
&.active {
opacity: 1;
}
.mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10000;
@include card();
opacity: 1;
transition: opacity $long-animation-time;
pointer-events: none;
@media (max-width: $mobile-width) {
opacity: 0 !important;
}
}
&.active .mask {
opacity: 0;
}
&.near-active .mask {
opacity: 0.55;
}
&:first-child {
margin-left: var(--large-padding);
}
&.placeholder {
opacity: 0 !important;
width: 60vw;
max-width: 60vw;
box-shadow: none;
background: transparent;
}
.header {
@include center-child();
position: relative;
.exit {
position: absolute;
left: 0;
@include exit();
}
.block-dot {
@include square(12px);
margin-right: 10px;
border-radius: 2px;
}
}
.select-add-container {
// When the create card has no tag chosen, glow the dropdown red as a
// gentle "required" cue — matches the legacy ghost-button affordance.
&.required-empty lt-select-add {
box-shadow: 0 0 0 0.75px rgba(181, 63, 63, 0.5);
border-radius: var(--border-radius);
}
}
.bottom {
height: 32px;
@media (max-width: $mobile-width) {
height: 24px;
}
position: relative;
button {
margin: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
}
}
}
`,
})
export class BlockEditComponent implements AfterViewInit {
readonly blocks = input.required<Block[]>();
readonly activeBlockId = input<string | null>(null);
readonly tags = input<string[]>([]);
readonly baseColor = input.required<HslColor>();
/** Default for `is_done` on the create card. */
readonly defaultDone = input<boolean>(true);
readonly save = output<BlockEditSave>();
readonly delete = output<string>();
readonly close = output<void>();
private readonly container =
viewChild<ElementRef<HTMLElement>>('container');
// Per-block edited values, keyed by block ID.
readonly editedValues = signal<Map<string, EditedValue>>(new Map());
// Pending new block being authored on the create card.
readonly newValue = signal<EditedValue>({
tag: '',
description: '',
is_done: true,
});
// 1-based index of the centered card. 0/N+2 are placeholders.
readonly activeIdx = signal(1);
private scrollToken = 0;
constructor() {
// Seed editedValues from input blocks (and re-seed if input changes).
effect(() => {
const bs = this.blocks();
const m = new Map<string, EditedValue>();
for (const b of bs) {
m.set(b.id, {
tag: b.tag,
description: b.description,
is_done: b.is_done,
});
}
untracked(() => this.editedValues.set(m));
});
// Seed the newValue tag from tags input on first run.
effect(() => {
const t = this.tags();
untracked(() => {
const cur = this.newValue();
if (!cur.tag && t.length > 0) {
this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() });
} else if (!cur.tag) {
this.newValue.set({ ...cur, is_done: this.defaultDone() });
}
});
});
}
ngAfterViewInit(): void {
// Position scroll on the focused card (or the create card if none).
queueMicrotask(() => {
const blocks = this.blocks();
const focusId = this.activeBlockId();
const focusIdx = focusId
? Math.max(0, blocks.findIndex((b) => b.id === focusId))
: blocks.length;
this.scrollToChild(focusIdx + 1, false);
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
editedFor(id: string): EditedValue {
return (
this.editedValues().get(id) ?? {
tag: '',
description: '',
is_done: false,
}
);
}
colorOfTagForBlock(id: string): string {
const v = this.editedFor(id);
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
}
colorOfNewTag = computed(() => {
const t = this.newValue().tag;
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
});
formatDate(ts: number): string {
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
// ── Existing-card mutations (auto-save on each change) ─────────────────────
updateTag(id: string, tag: string): void {
this.patchEdited(id, { tag });
this.flushExisting(id);
}
updateDescription(id: string, description: string): void {
this.patchEdited(id, { description });
// Description flush deferred to (blur) to avoid PUT per keystroke.
}
updateDone(id: string, is_done: boolean): void {
this.patchEdited(id, { is_done });
this.flushExisting(id);
}
private patchEdited(id: string, patch: Partial<EditedValue>): void {
this.editedValues.update((m) => {
const v = m.get(id);
if (!v) return m;
const next = new Map(m);
next.set(id, { ...v, ...patch });
return next;
});
}
flushExisting(id: string): void {
const v = this.editedFor(id);
if (!v.tag) return; // Skip empty saves
this.save.emit({ id, tag: v.tag, description: v.description, is_done: v.is_done });
}
onDelete(id: string): void {
this.delete.emit(id);
}
// ── Create-card mutations ──────────────────────────────────────────────────
updateNewTag(tag: string): void {
this.newValue.update((v) => ({ ...v, tag }));
}
updateNewDescription(description: string): void {
this.newValue.update((v) => ({ ...v, description }));
}
updateNewDone(is_done: boolean): void {
this.newValue.update((v) => ({ ...v, is_done }));
}
submitNew(): void {
const v = this.newValue();
if (!v.tag) return;
this.save.emit({
id: null,
tag: v.tag,
description: v.description,
is_done: v.is_done,
});
this.close.emit();
}
// ── Scroll handling ────────────────────────────────────────────────────────
onCardClick(idx: number): void {
if (idx !== this.activeIdx()) {
this.scrollToChild(idx, true);
}
}
/** Close the carousel when the user clicks anywhere that isn't a real card. */
onBackdropClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target && !target.closest('.card:not(.placeholder)')) {
this.close.emit();
}
}
onScroll(): void {
const token = ++this.scrollToken;
setTimeout(() => {
if (token === this.scrollToken) this.adjustPosition();
}, 150);
}
@HostListener('window:resize')
onResize(): void {
this.scrollToChild(this.activeIdx(), false);
}
private scrollToChild(idx: number, smooth: boolean): void {
const container = this.container()?.nativeElement;
if (!container) return;
const card = container.children.item(idx) as HTMLElement | null;
if (!card) return;
const left =
card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' });
this.activeIdx.set(idx);
}
private adjustPosition(): void {
const container = this.container()?.nativeElement;
if (!container) return;
const center = container.scrollLeft + container.clientWidth / 2;
let nearestIdx = 1;
let minDist = Infinity;
// children[0] and children[last] are the placeholders — skip.
for (let i = 1; i < container.children.length - 1; i++) {
const child = container.children.item(i) as HTMLElement;
const c = child.offsetLeft + child.offsetWidth / 2;
const d = Math.abs(c - center);
if (d < minDist) {
minDist = d;
nearestIdx = i;
}
}
if (nearestIdx !== this.activeIdx()) {
this.scrollToChild(nearestIdx, true);
}
}
}

View file

@ -1,11 +0,0 @@
<section
(click)="modalService.cancel()"
class="{{ modalService.active ? 'active' : '' }}"
[ngSwitch]="modalService.active?.type"
>
<app-blocks (save)="save = $event" (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.blocks"></app-blocks>
<app-remove-tower (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.removeTower"></app-remove-tower>
<app-settings (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.settings"></app-settings>
<app-get-started (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.getStarted"></app-get-started>
<app-remove-page (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.removePage"></app-remove-page>
</section>

View file

@ -1,29 +0,0 @@
@import '../../../styles';
section {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient;
transition: opacity 300ms;
&:not(.active) {
opacity: 0;
pointer-events: none;
}
button {
margin-top: var(--medium-padding);
}
}

View file

@ -1,25 +1,94 @@
import { Component } from '@angular/core';
import { ModalService, ModalType } from '../../services/modal.service';
import { CancelService } from '../../services/cancel.service';
import {
Component,
ChangeDetectionStrategy,
output,
signal,
AfterViewInit,
OnDestroy,
viewChild,
ElementRef,
inject,
} from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
import { ModalStateService } from '../../services/modal-state.service';
@Component({
selector: 'app-modal',
templateUrl: './modal.component.html',
styleUrls: ['./modal.component.scss']
})
export class ModalComponent {
ModalType = ModalType;
selector: 'lt-modal',
standalone: true,
imports: [A11yModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section
class="modal"
[class.active]="active()"
(click)="onBackdropClick($event)"
>
<div class="modal__dialog" #dialog cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="onClose()">
<ng-content></ng-content>
</div>
</section>
`,
styles: `
@import '../../../library/main';
save: () => void = null;
section.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient;
transition: opacity 300ms;
opacity: 1;
constructor(public modalService: ModalService, private cancelService: CancelService) {
this.cancelService.subscribe(this, () => {
if (this.save) {
this.save();
this.save = null;
} else {
this.modalService.cancel();
&:not(.active) {
opacity: 0;
pointer-events: none;
}
});
button {
margin-top: var(--medium-padding);
}
}
`,
})
export class ModalComponent implements AfterViewInit, OnDestroy {
readonly close = output<void>();
// The active signal starts false; AfterViewInit flips it true on next tick
// so the 300ms opacity transition fires on entry (0 → 1).
readonly active = signal(false);
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
private previousFocus: HTMLElement | null = null;
private escListener!: (e: KeyboardEvent) => void;
private readonly modalState = inject(ModalStateService);
ngAfterViewInit(): void {
this.previousFocus = document.activeElement as HTMLElement;
// Track open state so towers can be locked while any modal is mounted.
this.modalState.open();
// Defer one tick so the opacity transition runs (0 → 1).
setTimeout(() => this.active.set(true), 0);
}
ngOnDestroy(): void {
this.modalState.close();
this.previousFocus?.focus();
}
onBackdropClick(event: MouseEvent): void {
const dialog = this.dialogRef()?.nativeElement;
if (dialog && !dialog.contains(event.target as Node)) {
this.onClose();
}
}
onClose(): void {
this.close.emit();
}
}

View file

@ -1,85 +0,0 @@
<section #container *ngIf="tower">
<div class="card placeholder"></div>
<div
*ngFor="let i of range({ max: blocks.length })"
(click)="$event.stopPropagation(); scrollToChild(i + 1)"
class="card {{ i + 1 === activeChild ? 'active' : '' }} {{
i + 2 === activeChild || i === activeChild ? 'near-active' : ''
}}"
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<div class="block" [ngStyle]="{ 'background-color': tower.getColorOfTag(editedValues[i].tag) | color }"></div>
<h1 [innerText]="editedValues[i]?.created | formatDate"></h1>
</div>
<div class="select-add-container">
<app-select-add
class="select"
[options]="tower.tags"
[default]="editedValues[i].tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
[placeholder]="'Tag this item…'"
(value)="editedValues[i].tag = $event"
></app-select-add>
</div>
<textarea placeholder="Write a description here…" [(ngModel)]="editedValues[i].description"></textarea>
<div>
<app-toggle
[beforeText]="'This task hasn\'t been finished yet'"
[afterText]="'Goal already accomplished'"
[default]="blocks[i].isDone"
(value)="editedValues[i].isDone = $event"
></app-toggle>
</div>
</div>
<div
(click)="$event.stopPropagation(); scrollToChild(blocks.length + 1)"
class="card {{ blocks.length + 1 === activeChild ? 'active' : '' }} {{
blocks.length === activeChild ? 'near-active' : ''
}} "
>
<div class="mask"></div>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<div class="block" [ngStyle]="{ 'background-color': tower.getColorOfTag(top(editedValues).tag) | color }"></div>
<h1>Create now</h1>
</div>
<div class="select-add-container">
<app-select-add
class="select"
[options]="tower.tags"
[default]="tower.tags.length ? tower.tags[0] : null"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
[placeholder]="'Set a category…'"
[newValuePlaceholder]="'Add a category…'"
(value)="top(editedValues).tag = $event"
></app-select-add>
</div>
<textarea placeholder="Write a description here…" [(ngModel)]="top(editedValues).description"></textarea>
<div>
<app-toggle
[beforeText]="'This task hasn\'t been finished yet'"
[afterText]="'Goal already accomplished'"
[default]="onlyDone"
(value)="top(editedValues).isDone = $event"
></app-toggle>
</div>
<div class="bottom">
<button (click)="submitAdd()" [disabled]="!top(editedValues).tag">Create and exit</button>
</div>
</div>
<div class="card placeholder"></div>
</section>

View file

@ -1,175 +0,0 @@
@import '../../../../../styles';
:host {
@include center-child();
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow-x: auto;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
section {
width: 100%;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
}
}
.card {
@include card();
box-shadow: $shadow;
display: block;
transform-origin: center center;
flex: 0 0 auto;
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) {
width: 300px;
opacity: 1 !important;
}
box-sizing: border-box;
padding: var(--large-padding);
margin: calc(var(--large-padding) / 2);
position: relative;
&.near-active {
cursor: pointer;
}
.mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10000;
@include card();
@media (max-width: $mobile-width) {
opacity: 0 !important;
}
}
&:first-child {
margin-left: var(--large-padding);
}
&.placeholder {
opacity: 0 !important;
width: 60vw;
max-width: 60vw;
}
@include inner-spacing(var(--large-padding));
.header {
@include center-child();
position: relative;
.exit {
position: absolute;
left: 0;
@include exit();
}
.block {
@include square(12px);
margin-right: 10px;
}
}
.bottom {
height: 32px;
@media (max-width: $mobile-width) {
height: 24px;
}
position: relative;
button {
margin: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
transition: opacity $short-animation-time;
&.hidden {
opacity: 0;
}
}
.edit {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
opacity: 0.25;
cursor: pointer;
img {
@include square(16px);
}
transition: opacity $short-animation-time;
&:before {
content: '';
display: block;
position: absolute;
bottom: calc(-1 * #{$line-height});
left: 0;
height: $line-height;
background-color: $text-color;
width: 0;
transition: width $long-animation-time;
}
@media (min-width: $mobile-width) {
&:hover {
opacity: 0.5;
}
&:hover {
&:before {
width: 100% !important;
}
}
}
&.active {
&:before {
width: 100% !important;
}
}
&.active {
opacity: 1;
}
}
}
}
.card:last-child:after {
content: '';
height: 1px;
width: var(--large-padding);
right: calc(-1 * var(--large-padding));
display: block;
position: absolute;
}

View file

@ -1,183 +0,0 @@
import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
import { Tower } from '../../../../model/tower';
import { Observable } from 'rxjs/internal/Observable';
import { Block } from '../../../../model/block';
import { IBlock } from '../../../../interfaces/persistance/block';
import { CancelService } from '../../../../services/cancel.service';
import { range } from 'src/app/utils/range';
import { top } from 'src/app/utils/top';
@Component({
selector: 'app-blocks',
templateUrl: './blocks.component.html',
styleUrls: ['./blocks.component.scss']
})
export class BlocksComponent implements OnInit, OnDestroy {
readonly range = range;
readonly top = top;
tower: Tower;
editedValues: Array<Partial<IBlock>>;
endOfScrollToken = 0;
activeChild: number;
scrollMayEnd = true;
onlyDone: boolean;
@ViewChild('container') container: ElementRef;
private intervalID: number;
constructor(
public modalService: ModalService,
private cancelService: CancelService,
private changeDetector: ChangeDetectorRef,
private component: ElementRef
) {
window.addEventListener('resize', this.onScroll.bind(this));
}
@Output() save: EventEmitter<() => void> = new EventEmitter();
get blocks(): Array<Block> {
return this.tower.blocks.filter(b => b.isDone === this.onlyDone);
}
@HostListener('click') cancel() {
this.cancelService.cancelAll();
}
@HostListener('touchstart') fingerDown() {
this.scrollMayEnd = false;
}
@HostListener('touchend') fingerUp() {
this.scrollMayEnd = true;
this.onScroll();
}
@HostListener('scroll') onScroll() {
const newToken = ++this.endOfScrollToken;
setTimeout(() => {
if (newToken === this.endOfScrollToken && this.scrollMayEnd) {
this.adjustPosition();
}
}, 150);
this.animateScroll();
}
ngOnInit() {
const {
tower$,
onlyDone,
startBlock
}: { tower$: Observable<Tower>; onlyDone: boolean; startBlock: Block } = this.modalService.active.input;
this.save.emit(() => this.submitChange());
this.intervalID = setInterval(() => this.changeDetector.detectChanges(), 1000);
this.onlyDone = onlyDone;
const subscription = tower$.subscribe(value => {
if (value) {
this.tower = value;
this.editedValues = this.blocks.map(({ isDone, description, tag, created }) => ({
isDone,
description,
tag,
created
}));
this.editedValues.push({
tag: this.tower.tags.length ? this.tower.tags[0] : null,
isDone: this.onlyDone,
description: ''
});
setTimeout(() => {
this.scrollToChild(startBlock ? this.blocks.indexOf(startBlock) + 1 : this.blocks.length + 1, true);
subscription.unsubscribe();
});
}
});
}
animateScroll() {
if (!this.container || !this.component) {
return;
}
const c = this.component.nativeElement;
[...this.container.nativeElement.children]
.slice(1, -1)
.forEach(element =>
this.animate(
element.style,
element.querySelector('.mask').style,
Math.abs(element.offsetLeft - c.scrollLeft + element.clientWidth / 2 - window.innerWidth / 2) /
element.clientWidth
)
);
}
animate(cardStyle, maskStyle, t: number) {
t = Math.min(2, Math.max(0, t));
cardStyle.opacity = (1.33 * (1 - t / 2)).toString();
t = Math.min(1, Math.max(0, t));
maskStyle.opacity = Math.pow(t, 0.5).toString();
maskStyle.display = t <= 0.05 ? 'none' : 'block';
}
adjustPosition() {
if (!this.container || !this.component) {
return;
}
const c = this.component.nativeElement;
const middle =
[...this.container.nativeElement.children]
.slice(1, -1)
.map(element => Math.abs(element.offsetLeft - c.scrollLeft + element.clientWidth / 2 - window.innerWidth / 2))
.map((value, index) => (Math.abs(index + 1 - this.activeChild) === 1 ? Math.abs(value - 100) : value))
.reduce(
(middleIndex, current, currentIndex, list) => (list[middleIndex] < current ? middleIndex : currentIndex),
0
) + 1;
this.scrollToChild(middle);
}
scrollToChild(index: number, instantly?: boolean) {
this.activeChild = index;
const element = this.container.nativeElement.children[index];
this.component.nativeElement.scrollTo({
left: element.offsetLeft - (window.innerWidth / 2 - element.clientWidth / 2),
behavior: instantly ? 'auto' : 'smooth'
});
}
submitAdd() {
top(this.editedValues).created = new Date();
this.tower.addBlock(top(this.editedValues) as IBlock);
this.cancelService.cancelAll();
}
submitChange() {
this.blocks.forEach((b, i) => b.changeKeys(this.editedValues[i]));
this.modalService.submit();
}
ngOnDestroy() {
clearInterval(this.intervalID);
}
}

View file

@ -1,3 +0,0 @@
<p>
get-started works!
</p>

View file

@ -1,12 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-get-started',
templateUrl: './get-started.component.html',
styleUrls: ['./get-started.component.scss']
})
export class GetStartedComponent implements OnInit {
constructor() {}
ngOnInit() {}
}

View file

@ -1,13 +0,0 @@
<section>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<h1>Are you sure?</h1>
</div>
<p>
You are trying to remove <strong>{{ this.modalService.active.input }}</strong
>.
</p>
<button (click)="modalService.submit()">Remove</button>
</section>

View file

@ -1,30 +0,0 @@
@import '../../../../../styles';
section {
@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));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
}

View file

@ -1,11 +0,0 @@
import { Component } from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
@Component({
selector: 'app-remove-page',
templateUrl: './remove-page.component.html',
styleUrls: ['./remove-page.component.scss']
})
export class RemovePageComponent {
constructor(public modalService: ModalService) {}
}

View file

@ -1,14 +0,0 @@
<section>
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<h1>Are you sure?</h1>
</div>
<p>
You are trying to remove
<span [ngStyle]="{ color: tower.baseColor | color }">{{ tower.name ? tower.name : 'an unnamed tower' }}</span
>.
</p>
<button (click)="modalService.submit()">Remove</button>
</section>

View file

@ -1,30 +0,0 @@
@import '../../../../../styles';
section {
@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));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
}

View file

@ -1,14 +0,0 @@
import { Component } from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
import { Tower } from '../../../../model/tower';
@Component({
selector: 'app-remove-tower',
templateUrl: './remove-tower.component.html',
styleUrls: ['./remove-tower.component.scss']
})
export class RemoveTowerComponent {
constructor(public modalService: ModalService) {}
tower: Tower = this.modalService.active.input;
}

View file

@ -1,21 +0,0 @@
<div class="header">
<div class="exit" (click)="modalService.cancel()"></div>
<h1>Settings</h1>
</div>
<div>
<app-toggle
[beforeText]="'Hide create tower button'"
[afterText]="'Show create tower button'"
[default]="!page.userData.hideCreateTowerButton"
(value)="page.setHideCreateTowerButton(!$event)"
></app-toggle>
</div>
<p *ngIf="page.towers.length == 5">There can be a maximum of <strong>5</strong> towers on each page.</p>
<input id="token" type="text" [(ngModel)]="token" />
<button (click)="setNewToken()">Set token</button>
<button (click)="$event.stopPropagation() || deletePage()">Delete current page</button>

View file

@ -1,42 +0,0 @@
@import '../../../../../styles';
:host {
@include card();
width: 66vw;
max-width: 400px;
@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));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
p {
font-size: var(--medium-font-size);
}
input[type='text'] {
text-align: center;
}
button {
display: block;
}
}

View file

@ -1,56 +0,0 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ModalService } from '../../../../services/modal.service';
import { DataService } from '../../../../services/data.service';
import { Page } from '../../../../model/page';
import { Data } from '../../../../model/data';
import { Subscription } from 'rxjs/internal/Subscription';
import { MapStoreService } from '../../../../services/map-store.service';
@Component({
selector: 'app-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit, OnDestroy {
data: Data;
page: Page;
private dataSubscription: Subscription;
private pageSubscription: Subscription;
token: string;
constructor(public modalService: ModalService, private store: MapStoreService) {
this.token = store.userToken;
}
ngOnInit() {
const { data$, page$ } = this.modalService.active.input;
this.dataSubscription = data$.subscribe(d => (this.data = d));
this.pageSubscription = page$.subscribe(p => (this.page = p));
}
async deletePage() {
try {
await this.modalService.showRemovePage(this.page.name);
this.data.removePage(this.page);
this.modalService.submit();
} catch {
// pass
}
}
setNewToken() {
this.store.userToken = this.token;
}
ngOnDestroy() {
if (this.dataSubscription) {
this.dataSubscription.unsubscribe();
}
if (this.pageSubscription) {
this.pageSubscription.unsubscribe();
}
}
}

View file

@ -0,0 +1,141 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
OnInit,
inject,
signal,
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Page } from '../../models';
import { ToggleComponent } from '../shared/toggle/toggle.component';
export interface PageSettingsResult {
name: string;
hide_create_tower_button: boolean;
keep_tasks_open: boolean;
}
@Component({
selector: 'lt-page-settings',
standalone: true,
imports: [ReactiveFormsModule, ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="header">
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
<h2>{{ page() ? 'Page settings' : 'New page' }}</h2>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input
id="ps-name"
type="text"
formControlName="name"
maxlength="200"
autocomplete="off"
placeholder="Page name…"
/>
<div class="toggle-row">
<lt-toggle
[checked]="hideCreateTowerButton()"
(checkedChange)="hideCreateTowerButton.set($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
</div>
<div class="toggle-row">
<lt-toggle
[checked]="keepTasksOpen()"
(checkedChange)="keepTasksOpen.set($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
</div>
<button type="submit" [disabled]="form.invalid">
{{ page() ? 'Save' : 'Create page' }}
</button>
@if (page()) {
<button type="button" (click)="delete.emit()">Delete page</button>
}
</form>
`,
styles: `
@import '../../../library/main';
:host {
@include card();
width: 66vw;
max-width: 400px;
@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));
display: block;
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
input[type='text'] {
text-align: center;
}
.toggle-row {
display: flex;
justify-content: center;
}
button {
display: block;
}
}
`,
})
export class PageSettingsComponent implements OnInit {
readonly page = input<Page | null>(null);
readonly save = output<PageSettingsResult>();
readonly delete = output<void>();
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
});
hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
ngOnInit(): void {
const p = this.page();
if (p) {
this.form.patchValue({ name: p.name });
this.hideCreateTowerButton.set(p.hide_create_tower_button);
this.keepTasksOpen.set(p.keep_tasks_open);
}
}
onSubmit(): void {
if (this.form.invalid) return;
const v = this.form.value;
this.save.emit({
name: v.name ?? '',
hide_create_tower_button: this.hideCreateTowerButton(),
keep_tasks_open: this.keepTasksOpen(),
});
}
}

View file

@ -0,0 +1,266 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
} from '@angular/core';
import { Page } from '../../models';
import { ToggleComponent } from '../shared/toggle/toggle.component';
export interface UpdatePagePayload {
name: string;
hide_create_tower_button: boolean;
keep_tasks_open: boolean;
}
const UUIDV4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Component({
selector: 'lt-settings',
standalone: true,
imports: [ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Settings</h2>
@if (page()) {
<section class="page-section">
<h3>This page</h3>
<input
type="text"
[value]="pageName()"
(blur)="onRenamePage($any($event.target).value)"
placeholder="Page name…"
maxlength="200"
autocomplete="off"
aria-label="Page name"
/>
<lt-toggle
[checked]="hideCreateTowerButton()"
(checkedChange)="onHideCreateTowerButtonChange($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<lt-toggle
[checked]="keepTasksOpen()"
(checkedChange)="onKeepTasksOpenChange($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
<button class="danger" type="button" (click)="deletePage.emit()">
Delete this page
</button>
</section>
<hr />
}
<section class="account-section">
<h3>Account</h3>
<p class="hint">Your token (keep it secret it IS your account)</p>
<div class="token-row">
<input
type="text"
readonly
[value]="token()"
aria-label="Your account token"
/>
<button type="button" (click)="onCopy()">Copy</button>
</div>
<p class="hint">Paste a token to switch accounts</p>
<div class="token-row">
<input
type="text"
[class.error]="tokenInputTouched() && tokenInput() && !isValidToken()"
placeholder="Paste a UUID…"
[value]="tokenInput()"
(input)="onTokenInput($any($event.target).value)"
(blur)="tokenInputTouched.set(true)"
aria-label="Paste token to switch account"
/>
<button type="button" [disabled]="!isValidToken()" (click)="onSwitch()">
Switch
</button>
</div>
@if (tokenInputTouched() && tokenInput() && !isValidToken()) {
<p class="error-message">Not a valid token. Must be a UUIDv4.</p>
}
</section>
</div>
`,
styles: `
@import '../../../library/main';
:host {
display: block;
}
.card {
@include card();
width: 66vw;
max-width: 480px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
text-align: left;
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
font-size: 0;
}
h2 {
margin: 0 0 var(--large-padding) 0;
text-align: center;
}
h3 {
margin: 0 0 var(--medium-padding) 0;
font-size: var(--large-font-size);
}
section {
@include inner-spacing(var(--medium-padding));
margin-bottom: var(--large-padding);
&:last-child {
margin-bottom: 0;
}
}
hr {
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin: var(--large-padding) 0;
}
.hint {
font-size: var(--small-font-size);
color: rgba($text-color, 0.7);
margin: 0 0 4px 0;
}
.token-row {
display: flex;
gap: var(--small-padding);
align-items: center;
input {
flex: 1;
min-width: 0;
text-align: left;
}
button {
margin: 0;
flex: 0 0 auto;
}
}
input.error {
box-shadow: 0 1px #b53f3f;
}
.error-message {
color: #b53f3f;
font-size: var(--small-font-size);
margin-top: 4px;
}
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after {
background-color: #b53f3f;
}
}
}
`,
})
export class SettingsComponent {
readonly token = input.required<string>();
readonly page = input<Page | null>(null);
readonly close = output<void>();
readonly switchAccount = output<string>();
readonly updatePage = output<UpdatePagePayload>();
readonly deletePage = output<void>();
// Page-settings state — seeded from page() input
readonly pageName = signal('');
readonly hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
// Token-switch state
readonly tokenInput = signal('');
readonly tokenInputTouched = signal(false);
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
constructor() {
effect(() => {
const p = this.page();
if (p) {
this.pageName.set(p.name);
this.hideCreateTowerButton.set(p.hide_create_tower_button);
this.keepTasksOpen.set(p.keep_tasks_open);
}
});
}
onRenamePage(value: string): void {
const trimmed = value.trim();
if (!trimmed) return;
this.pageName.set(trimmed);
this.flushPageUpdate();
}
onHideCreateTowerButtonChange(value: boolean): void {
this.hideCreateTowerButton.set(value);
this.flushPageUpdate();
}
onKeepTasksOpenChange(value: boolean): void {
this.keepTasksOpen.set(value);
this.flushPageUpdate();
}
private flushPageUpdate(): void {
this.updatePage.emit({
name: this.pageName(),
hide_create_tower_button: this.hideCreateTowerButton(),
keep_tasks_open: this.keepTasksOpen(),
});
}
onCopy(): void {
navigator.clipboard.writeText(this.token()).catch(() => {});
}
onTokenInput(value: string): void {
this.tokenInput.set(value.trim());
}
onSwitch(): void {
if (!this.isValidToken()) return;
this.switchAccount.emit(this.tokenInput());
this.close.emit();
}
}

View file

@ -0,0 +1,124 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
OnInit,
inject,
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Tower, HslColor } from '../../models';
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
export interface TowerSettingsResult {
name: string;
base_color: HslColor;
}
@Component({
selector: 'lt-tower-settings',
standalone: true,
imports: [ReactiveFormsModule, ColorPickerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="header">
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
<h2>{{ tower() ? 'Tower settings' : 'New tower' }}</h2>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input
id="ts-name"
name="towerName"
type="text"
formControlName="name"
placeholder="Tower name…"
maxlength="200"
autocomplete="off"
/>
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
<button type="submit" [disabled]="form.invalid">
{{ tower() ? 'Save' : 'Create tower' }}
</button>
@if (tower()) {
<button type="button" (click)="delete.emit()">Delete tower</button>
}
</form>
`,
styles: `
@import '../../../library/main';
:host {
@include card();
width: 66vw;
max-width: 400px;
@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));
display: block;
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit();
}
}
input[type='text'] {
text-align: center;
}
button {
display: block;
}
}
`,
})
export class TowerSettingsComponent implements OnInit {
readonly tower = input<Tower | null>(null);
readonly save = output<TowerSettingsResult>();
readonly delete = output<void>();
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
});
currentColor: HslColor = randomDefaultColor();
ngOnInit(): void {
const t = this.tower();
if (t) {
this.form.patchValue({ name: t.name });
this.currentColor = { ...t.base_color };
}
}
onColorChange(color: HslColor): void {
this.currentColor = color;
}
onSubmit(): void {
if (this.form.invalid) return;
const v = this.form.value;
this.save.emit({ name: v.name ?? '', base_color: this.currentColor });
}
}
function randomDefaultColor(): HslColor {
// Pick a hue in [0°, 30°] [200°, 360°] — warm or cool, avoid green.
const warm = Math.random() < 0.5;
const hueDeg = warm ? Math.random() * 30 : 200 + Math.random() * 160;
return { h: hueDeg / 360, s: 0.7, l: 0.55 };
}

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

@ -1,10 +1,11 @@
@import '../../../../styles';
@import '../../../styles';
:host {
display: flex;
flex-direction: column;
height: 100%;
position: relative; // anchor for absolute-positioned .trash
@include inner-spacing(var(--large-padding));
@ -32,7 +33,7 @@
}
}
div {
.add-tower-wrapper {
@include center-child();
img.add-tower {
height: 48px;
@ -65,7 +66,7 @@
position: relative;
@for $i from 1 to 6 {
@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});
@ -78,8 +79,47 @@
}
.double-slider-container {
@media (max-height: $min-height) {
display: none;
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; }
}
}
}

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');
}
}

View file

@ -1,31 +0,0 @@
<section class="towers" cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="dropDrag($event)">
<app-tower
*ngFor="let tower of towers"
[tower$]="tower.asObservable()"
[dateRange$]="dateRange"
cdkDrag
(cdkDragStarted)="startDrag(towers.indexOf(tower))"
></app-tower>
<div *ngIf="(page$ | async)?.towers.length < 5 && !(page$ | async)?.userData?.hideCreateTowerButton">
<img src="assets/plus-sign.svg" alt="add tower" class="add-tower" (click)="page.addTower()" />
</div>
</section>
<img
[ngClass]="isDragging ? 'active' : ''"
src="assets/trash.svg"
alt="trashcan"
class="trash"
(pointerenter)="trashEnter()"
(pointerleave)="trashExit()"
(pointerup)="removeTower()"
/>
<div class="double-slider-container" [ngStyle]="{ opacity: isDragging ? '0' : '1' }">
<app-double-slider
*ngIf="dates.length >= MIN_BLOCK_COUNT_BEFORE_SHOWING_SLIDER"
[values]="dates"
[labels]="dateLabels"
(range)="dateRange.next($event)"
></app-double-slider>
</div>

View file

@ -1,92 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Page } from '../../../model/page';
import { ModalService } from '../../../services/modal.service';
import { Observable } from 'rxjs/internal/Observable';
import { Range } from '../../../interfaces/range';
import { Subject } from 'rxjs/internal/Subject';
import { Tower } from '../../../model/tower';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({
selector: 'app-page',
templateUrl: './page.component.html',
styleUrls: ['./page.component.scss']
})
export class PageComponent implements OnInit {
@Input() page$: Observable<Page>;
private page: Page;
towers: Array<BehaviorSubject<Tower>> = [];
@Output() isDragHappening: EventEmitter<boolean> = new EventEmitter();
readonly MIN_BLOCK_COUNT_BEFORE_SHOWING_SLIDER = 6;
isDragging = false;
draggedTowerIndex: number;
nearTrashcan = false;
dates: Date[] = [];
dateRange: Subject<Range<Date>> = new Subject<Range<Date>>();
get dateLabels(): string[] {
return this.dates.map(d => d.toLocaleDateString());
}
constructor(private modalService: ModalService) {}
ngOnInit(): void {
this.page$.subscribe(value => {
if (value) {
this.towers = value.towers.map((t, index) => {
if (index < this.towers.length) {
this.towers[index].next(t);
return this.towers[index];
}
return new BehaviorSubject(t);
});
this.page = value;
this.dates = value.towers
.reduce((all, t) => [...t.blocks.map(b => b.created), ...all], [])
.sort((d1, d2) => d1.getTime() - d2.getTime());
}
});
}
dropDrag(event: any) {
this.page.moveTower(event);
this.isDragging = false;
this.isDragHappening.emit(false);
}
startDrag(id: number) {
this.draggedTowerIndex = id;
this.isDragging = true;
this.isDragHappening.emit(true);
}
trashEnter() {
this.nearTrashcan = true;
window.document.querySelector('.cdk-drag-preview').className += ' trash-highlight';
}
trashExit() {
this.nearTrashcan = false;
const elem = window.document.querySelector('.cdk-drag-preview');
elem.className = elem.className
.split(' ')
.slice(0, -1)
.join(' ');
}
async removeTower() {
try {
const tower = this.page.towers[this.draggedTowerIndex];
await this.modalService.showRemoveTower(tower);
this.page.removeTower(tower);
} catch {
// pass
}
}
}

View file

@ -1 +0,0 @@
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="$event.stopPropagation() || handleClick()"></div>

View file

@ -1,15 +0,0 @@
@import '../../../../../../styles';
:host {
position: relative;
width: calc(100% / 6);
padding-bottom: calc(100% / 6);
div {
position: absolute;
width: 100%;
height: 100%;
@include gravitate();
}
}

View file

@ -1,28 +0,0 @@
import { ChangeDetectorRef, Component, Input } from '@angular/core';
import { ModalService } from '../../../../../services/modal.service';
import { ColoredBlock, Tower } from '../../../../../model/tower';
import { Observable } from 'rxjs/internal/Observable';
@Component({
selector: 'app-block',
templateUrl: './block.component.html',
styleUrls: ['./block.component.scss']
})
export class BlockComponent {
@Input() block: ColoredBlock;
@Input() tower$: Observable<Tower>;
constructor(private modalService: ModalService) {}
async handleClick() {
try {
await this.modalService.showBlocks({
tower$: this.tower$,
startBlock: this.block,
onlyDone: true
});
} catch {
// pass
}
}
}

View file

@ -1,15 +0,0 @@
<div *ngIf="tasks" class="container {{ tasks.length > 0 ? 'show-hover' : '' }}" (click)="$event.stopPropagation()">
<p class="header" (click)="isOpen = !isOpen">
<strong>
{{ tasks.length == 0 ? '' : tasks.length }}
</strong>
<!-- &#8203; is the zero width space -->
{{ tasks.length == 0 ? '&#8203;' : tasks.length == 1 ? 'task' : 'tasks' }}
</p>
<div class="all-task" #allTask [ngStyle]="{ height: (isOpen ? allTask?.scrollHeight : 0) + 'px' }">
<div class="task-container" *ngFor="let task of tasks" [ngStyle]="{ color: task.color | color }">
<div [ngStyle]="{ 'background-color': task.color | color }"></div>
<p (click)="handleClick(task)" [innerText]="task.description ? task.description : 'unknown'"></p>
</div>
</div>
</div>

View file

@ -1,80 +0,0 @@
@import '../../../../../../styles';
:host {
width: 100%;
box-sizing: border-box;
position: relative;
z-index: 100000;
.container {
@include card();
cursor: pointer;
transition: box-shadow $long-animation-time;
&.show-hover:hover {
box-shadow: $shadow-border;
}
padding: calc(var(--small-padding) / 2);
margin: calc(var(--small-padding) / 2);
max-height: 30vh;
overflow-y: auto;
.header {
cursor: pointer;
}
p {
font-size: var(--medium-font-size);
}
.all-task {
@include inner-spacing(var(--small-padding));
:first-child {
margin-top: var(--small-padding);
}
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
.task-container {
display: flex;
align-items: center;
&:hover {
p {
@media (min-width: $mobile-width) {
color: inherit !important;
}
}
}
div {
flex: 0 0 auto;
margin: 0 calc(var(--small-padding) / 2) 0 0;
@include square(var(--small-padding));
@media (max-width: $mobile-width) {
@include square(calc(var(--small-padding) / 2));
}
}
p {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
}
position: relative;
}
}
}
}
}

View file

@ -1,55 +0,0 @@
import { ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core';
import { Block } from '../../../../../model/block';
import { Tower } from '../../../../../model/tower';
import { ModalService } from '../../../../../services/modal.service';
import { CancelService } from '../../../../../services/cancel.service';
import { IColor } from '../../../../../interfaces/color';
import { Observable } from 'rxjs/internal/Observable';
@Component({
selector: 'app-tasks',
templateUrl: './tasks.component.html',
styleUrls: ['./tasks.component.scss']
})
export class TasksComponent {
@Input() tasks: Array<Block & { color: IColor }>;
@Input() tower$: Observable<Tower>;
private _isOpen = false;
@Input() set isOpen(value: boolean) {
if (value) {
this.cancelService.cancelAllExcept(this);
}
this._isOpen = value;
}
get isOpen(): boolean {
return this._isOpen;
}
@ViewChild('allTask') allTask: ElementRef;
constructor(
private modalService: ModalService,
private cancelService: CancelService,
private changeDetection: ChangeDetectorRef
) {
this.cancelService.subscribe(this, () => {
this.isOpen = false;
});
}
async handleClick(block: Block) {
try {
await this.modalService.showBlocks({
tower$: this.tower$,
startBlock: block,
onlyDone: false
});
} catch {
// pass
} finally {
this.changeDetection.markForCheck();
}
}
}

View file

@ -1,31 +0,0 @@
<div class="tower" *ngIf="tower$ | async">
<div class="container">
<div class="tasks-container">
<app-tasks [tasks]="tasks" [tower$]="tower$"></app-tasks>
</div>
<img src="assets/plus-sign.svg" alt="add item" (click)="$event.stopPropagation() || addBlock()" />
<div class="block-container-container">
<div class="block-container" *ngIf="styledBlocks.length > 0">
<app-block
*ngFor="let block of drawableBlocks"
[ngClass]="block.cssClass"
[ngStyle]="block.style"
[block]="block"
[tower$]="tower$"
></app-block>
</div>
</div>
</div>
<label class="hidden">
Card name
<input
type="text"
placeholder="name…"
[(ngModel)]="towerName"
[ngStyle]="{ color: (tower$ | async)?.baseColor | color }"
/>
</label>
</div>

View file

@ -1,152 +0,0 @@
@import '../../../../../styles';
:host {
cursor: pointer;
&.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
&.cdk-drag-placeholder {
opacity: 0;
}
&:hover {
@media (min-width: $mobile-width) {
div.container {
box-shadow: $shadow;
}
}
}
&.cdk-drag-preview {
div.container {
@media (max-width: $mobile-width) {
@keyframes shadow {
from {
box-shadow: none;
}
to {
box-shadow: $shadow;
}
}
animation: shadow $long-animation-time forwards;
}
}
}
&.trash-highlight {
.container {
transform: scale(0.75);
position: relative;
:before {
opacity: 0.5 !important;
}
}
input {
display: none;
}
}
.tower {
display: flex;
flex-direction: column;
align-items: center;
max-width: 100%;
height: 100%;
@include inner-spacing(var(--small-padding));
.container {
display: flex;
flex-direction: column;
flex: 1 1 auto;
position: relative;
@include card();
overflow: hidden;
transition: transform $short-animation-time, box-shadow $long-animation-time;
@include inner-spacing(var(--medium-padding));
width: 100%;
:before {
content: '';
pointer-events: none;
position: absolute;
z-index: 2;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: red;
opacity: 0;
border-radius: var(--border-radius);
transition: opacity $short-animation-time;
}
img {
position: relative;
z-index: 2;
height: 48px;
@media (max-width: $mobile-width) {
height: 32px;
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.block-container-container {
position: relative;
flex: 1;
.block-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-end;
position: absolute;
bottom: 0;
width: 100%;
transform: scaleY(-1);
* {
transform: translateY(500%);
}
.descend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), opacity 500ms cubic-bezier(0.5, 0, 1, 0);
}
.ascend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
}
}
}
}
input[type='text'] {
font-size: var(--small-font-size);
text-align: center;
@media (min-width: $mobile-width) {
width: 50%;
}
}
}
}

View file

@ -1,123 +0,0 @@
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { ColoredBlock, Tower } from '../../../../model/tower';
import { ModalService } from '../../../../services/modal.service';
import { Observable } from 'rxjs/internal/Observable';
import { Range } from '../../../../interfaces/range';
import { top } from '../../../../utils/top';
import { CancelService } from '../../../../services/cancel.service';
type StyledBlock = ColoredBlock & { style: { [p: string]: string }; shouldDraw: boolean; cssClass: string };
@Component({
selector: 'app-tower',
templateUrl: './tower.component.html',
styleUrls: ['./tower.component.scss']
})
export class TowerComponent implements OnInit {
@Input() dateRange$: Observable<Range<Date>>;
@Input() tower$: Observable<Tower>;
private dateRange: Range<Date>;
private tower: Tower;
get towerName(): string {
return this.tower ? this.tower.name : 'Loading…';
}
set towerName(value: string) {
this.tower.changeName(value);
}
tasks: Array<ColoredBlock>;
styledBlocks: Array<StyledBlock> = [];
get drawableBlocks(): Array<StyledBlock> {
return this.styledBlocks.filter(b => b.shouldDraw);
}
public constructor(private modalService: ModalService, private changeDetection: ChangeDetectorRef) {}
ngOnInit() {
this.tower$.subscribe(value => {
// console.log(this.tower, value);
if (value) {
this.styledBlocks = value.coloredBlocks
.filter(b => b.isDone)
.map(b => {
const classedBlock = b as StyledBlock;
classedBlock.shouldDraw = true;
classedBlock.style = { transform: 'translateY(0)', opacity: '1' };
classedBlock.cssClass = '';
return classedBlock;
});
if (this.tower && this.tower.latestVersion === value) {
const difference = this.tower.blocks.map((b, index) => {
return b === value.blocks[index];
});
if (
(difference.every(i => i) &&
this.tower.blocks.length + 1 === value.blocks.length &&
top(value.blocks).isDone) ||
(this.tower.blocks.length === value.blocks.length &&
this.tower.blocks.filter(b => b.isDone).length + 1 === value.blocks.filter(b => b.isDone).length)
) {
const lastBlock = top(this.styledBlocks);
if (lastBlock) {
lastBlock.style = { transform: 'translateY(500%)', opacity: '0' };
setTimeout(() => {
this.makeBlockDescend(lastBlock);
this.changeDetection.markForCheck();
}, 0);
}
}
}
this.tasks = value.coloredBlocks.filter(block => !block.isDone);
this.tower = value;
this.changeDetection.markForCheck();
}
});
this.dateRange$.subscribe(dateRange => {
this.initData(dateRange);
this.dateRange = dateRange;
});
}
makeBlockDescend(block: StyledBlock) {
block.cssClass = 'descend';
block.style = { transform: 'translateY(0)', opacity: '1' };
}
makeBlockAscend(block: StyledBlock) {
block.cssClass = 'ascend';
block.style = { transform: 'translateY(500%)', opacity: '0' };
}
initData(newDateRange: Range<Date>) {
for (const block of this.styledBlocks) {
block.shouldDraw = newDateRange.from <= block.created;
if (newDateRange.to < block.created) {
this.makeBlockAscend(block);
}
if (block.shouldDraw && block.created <= newDateRange.to) {
this.makeBlockDescend(block);
}
}
}
public async addBlock() {
try {
await this.modalService.showBlocks({
tower$: this.tower$,
onlyDone: true
});
} catch {
// pass
}
}
}

View file

@ -1,22 +1,44 @@
<div class="select-add-container">
<!-- wrapper for easier styling -->
<app-select-add
[options]="pageNames"
[default]="(selectedPage$ | async)?.name"
(value)="selectPage($event)"
(optionChange)="changeName($event)"
[placeholder]="'Add a new page…'"
[editable]="true"
></app-select-add>
<lt-select-add
[options]="pageNames()"
[selectedIndex]="selectedPageIndex()"
placeholder="Add a new page…"
(selectionChange)="onSelectPage($event)"
(add)="onAddPage($event)"
/>
</div>
<!-- wrapper for easier styling -->
<div class="page-container">
<!-- wrapper for easier styling -->
<app-page [page$]="selectedPage$" (isDragHappening)="isDragHappening = $event"></app-page>
@if (selectedPage(); as page) {
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" />
} @else {
<p>Add a new page to get started!</p>
}
</div>
<!-- wrapper for easier styling -->
<button [ngClass]="isDragHappening ? 'transparent' : ''" (click)="$event.stopPropagation(); openSettings()">
<button [class.transparent]="dragHappening()" (click)="showSettings.set(true)">
Settings
</button>
@if (showSettings()) {
<lt-modal (close)="showSettings.set(false)">
<lt-settings
[token]="store.token()"
[page]="selectedPage()"
(close)="showSettings.set(false)"
(updatePage)="onUpdatePage($event)"
(deletePage)="onRemovePage()"
(switchAccount)="onSwitchAccount($event)"
/>
</lt-modal>
}
@if (showWelcome()) {
<lt-modal (close)="showWelcome.set(false)">
<lt-welcome
(close)="showWelcome.set(false)"
(startFresh)="showWelcome.set(false)"
(loadExample)="onLoadExample()"
/>
</lt-modal>
}

View file

@ -13,6 +13,8 @@
width: 250px;
margin-left: auto;
margin-right: auto;
position: relative;
z-index: 1000;
}
.page-container {

View file

@ -1,113 +1,108 @@
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Page } from '../../model/page';
import { DataService } from '../../services/data.service';
import { ModalService } from '../../services/modal.service';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Observable } from 'rxjs/internal/Observable';
import { Data } from '../../model/data';
import { of } from 'rxjs/internal/observable/of';
const USER_DATA_KEY = 'life-towers.user-data.v.2';
import {
Component,
ChangeDetectionStrategy,
inject,
signal,
computed,
effect,
} from '@angular/core';
import { StoreService } from '../../services/store.service';
import { PageComponent } from '../page/page.component';
import { ModalComponent } from '../modal/modal.component';
import { SettingsComponent, UpdatePagePayload } from '../modal/settings.component';
import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { WelcomeComponent } from '../welcome/welcome.component';
import { Page } from '../../models';
@Component({
selector: 'app-pages',
selector: 'lt-pages',
standalone: true,
imports: [PageComponent, ModalComponent, SettingsComponent, SelectAddComponent, WelcomeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './pages.component.html',
styleUrls: ['./pages.component.scss']
styleUrl: './pages.component.scss',
})
export class PagesComponent implements OnInit {
@ViewChild('top') top: ElementRef;
@ViewChild('page') page: ElementRef;
@ViewChild('bottom') bottom: ElementRef;
export class PagesComponent {
protected readonly store = inject(StoreService);
data: Data;
pages: Array<Page>;
isDragHappening = false;
/** ID of currently selected page within store.pages(). */
private readonly selectedPageId = signal<string | null>(null);
get pageNames() {
if (this.pages) {
return this.pages.map(p => p.name);
}
return [];
}
readonly showSettings = signal(false);
readonly dragHappening = signal(false);
readonly showWelcome = signal(false);
selectedPageName: string;
private readonly _selectedPage: BehaviorSubject<Page> = new BehaviorSubject(null);
readonly selectedPage$: Observable<Page> = this._selectedPage.asObservable();
constructor(
public dataService: DataService,
private modalService: ModalService,
private changeDetection: ChangeDetectorRef
) {
const userData = JSON.parse(window.localStorage.getItem(USER_DATA_KEY));
if (userData !== null) {
this.selectedPageName = userData.selectedPage;
}
}
ngOnInit() {
this.dataService.children$.subscribe(dataContainer => {
if (dataContainer && dataContainer.length > 0) {
this.data = dataContainer[0];
const pages = this.data.pages;
if (this.pages && !pages.includes(this._selectedPage.getValue().latestVersion)) {
this.selectedPageName = null;
}
this.pages = pages;
this.selectPage(this.selectedPageName);
constructor() {
effect(() => {
if (!this.store.loading() && this.store.pages().length === 0) {
this.showWelcome.set(true);
} else if (this.store.pages().length > 0) {
this.showWelcome.set(false);
}
});
}
changeName({ from, to }: { from: string; to: string }) {
const page = this.pages.find(p => p.name === from);
onLoadExample(): void {
this.store.loadExample();
this.showWelcome.set(false);
}
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
readonly selectedPage = computed<Page | null>(() => {
const pages = this.store.pages();
if (pages.length === 0) return null;
const id = this.selectedPageId();
if (id) {
const found = pages.find((p) => p.id === id);
if (found) return found;
}
// Default to first page.
return pages[0] ?? null;
});
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
readonly selectedPageIndex = computed(() => {
const page = this.selectedPage();
if (!page) return -1;
return this.store.pages().findIndex((p) => p.id === page.id);
});
onSelectPage(index: number): void {
const pages = this.store.pages();
const page = pages[index];
if (page) {
if (from === this.selectedPageName) {
this.selectedPageName = to;
}
page.changeName(to);
this.selectedPageId.set(page.id);
}
}
selectPage(name: string) {
if (!name) {
if (this.pages && this.pages.length > 0) {
name = this.pages[0].name;
}
onAddPage(name: string): void {
this.store.addPage(name);
// Select the newly added page.
const pages = this.store.pages();
const newPage = pages[pages.length - 1];
if (newPage) {
this.selectedPageId.set(newPage.id);
}
this.selectedPageName = name;
window.localStorage.setItem(
USER_DATA_KEY,
JSON.stringify({
selectedPage: name
})
);
if (this.pages && name) {
if (!this.pageNames.includes(name)) {
this.data.addPage(name);
}
const index = this.pageNames.indexOf(name);
this._selectedPage.next(this.pages[index]);
return;
}
this._selectedPage.next(null);
}
async openSettings() {
try {
await this.modalService.showSettings({
page$: this.selectedPage$,
data$: of(this.data)
});
} catch {
// pass
} finally {
this.changeDetection.markForCheck();
onUpdatePage(payload: UpdatePagePayload): void {
const page = this.selectedPage();
if (page) {
this.store.updatePage(page.id, payload);
}
}
onRemovePage(): void {
const page = this.selectedPage();
if (!page) return;
this.store.deletePage(page.id);
this.selectedPageId.set(null);
this.showSettings.set(false);
}
onSwitchAccount(token: string): void {
this.store.switchToken(token);
}
}

View file

@ -0,0 +1,187 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
computed,
} from '@angular/core';
import { HslColor } from '../../../models';
import { toCss } from '../../../utils/color';
// 12 hand-picked hues. Rationale:
// Warm cluster (045°): coral/red, orange-red, orange, amber — vivid warm tones
// Skipped 60180° (yellows + greens) — most read as muddy next to the rose UI accent
// Cool cluster (195260°): sky-cyan, azure, blue, indigo — clean, distinct from rose
// Purple-rose cluster (280355°): violet, magenta, rose-pink, near-red — complements the accent
const PRESETS: number[] = [0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355];
const FIXED_S = 0.7;
const FIXED_L = 0.55;
@Component({
selector: 'lt-color-picker',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="picker">
<div class="swatches" role="group" aria-label="Preset colors">
@for (h of presetHues; track h) {
<button
type="button"
class="swatch"
[class.active]="isActiveHue(h)"
[style.background-color]="hueToCss(h)"
[attr.aria-label]="'Pick hue ' + h + ' degrees'"
[attr.aria-pressed]="isActiveHue(h)"
(click)="pickHue(h)"
></button>
}
</div>
<div class="hue-slider">
<input
type="range"
min="0"
max="360"
step="1"
[value]="hueDeg()"
(input)="onSlider($any($event.target).value)"
[style.--thumb-color]="toCss(color())"
aria-label="Hue"
/>
</div>
<div class="preview" [style.background-color]="toCss(color())" aria-hidden="true"></div>
</div>
`,
styles: `
@import '../../../../library/main';
:host {
display: block;
padding: var(--medium-padding);
@include card();
box-shadow: $shadow-border;
}
.picker {
display: flex;
flex-direction: column;
gap: var(--medium-padding);
width: 100%;
}
.swatches {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 6px;
.swatch {
all: unset;
cursor: pointer;
aspect-ratio: 1;
border-radius: 4px;
box-shadow: $shadow-border;
transition: transform $short-animation-time, box-shadow $long-animation-time;
&:hover,
&:focus-visible {
box-shadow: $shadow;
transform: scale(1.1);
}
&.active {
box-shadow: $shadow;
transform: scale(1.15);
outline: 2px solid $light-color;
outline-offset: 1px;
}
}
}
.hue-slider {
input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 12px;
border-radius: 1000px;
background: linear-gradient(
to right,
hsl(0, 70%, 55%),
hsl(60, 70%, 55%),
hsl(120, 70%, 55%),
hsl(180, 70%, 55%),
hsl(240, 70%, 55%),
hsl(300, 70%, 55%),
hsl(360, 70%, 55%)
);
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 24px;
width: 24px;
border-radius: 1000px;
background-color: var(--thumb-color, #{$light-color});
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
cursor: grab;
&:active {
cursor: grabbing;
}
}
&::-moz-range-thumb {
height: 24px;
width: 24px;
border-radius: 1000px;
background-color: var(--thumb-color, white);
border: 2px solid white;
box-shadow: $shadow;
cursor: grab;
&:active {
cursor: grabbing;
}
}
}
}
.preview {
height: 40px;
border-radius: var(--border-radius);
box-shadow: $shadow-border;
}
`,
})
export class ColorPickerComponent {
readonly color = input.required<HslColor>();
readonly colorChange = output<HslColor>();
readonly presetHues = PRESETS;
readonly hueDeg = computed(() => Math.round(this.color().h * 360));
isActiveHue(h: number): boolean {
return Math.abs(this.hueDeg() - h) < 8;
}
hueToCss(h: number): string {
return `hsl(${h}, 70%, 55%)`;
}
toCss(c: HslColor): string {
return toCss(c);
}
pickHue(h: number): void {
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
}
onSlider(value: string): void {
this.colorChange.emit({ h: Number(value) / 360, s: FIXED_S, l: FIXED_L });
}
}

View file

@ -1,13 +0,0 @@
<div class="container">
<label for="date-selector-1">date selector 1</label>
<label for="date-selector-2">date selector 2</label>
<input id="date-selector-1" type="range" min="0" [max]="MAX - 1" [(ngModel)]="oneValue" />
<input id="date-selector-2" type="range" min="0" [max]="MAX - 1" [(ngModel)]="otherValue" />
<div class="value-container">
<span
*ngFor="let i of drawnLabelsIndices"
[innerHTML]="drawnLabels[i]"
[ngStyle]="{ transform: getOffset(i) }"
></span>
</div>
</div>

View file

@ -1,77 +0,0 @@
@import '../../../../styles';
$height: 70px;
$width: 300px;
$slider-size: 40px;
.container {
width: $width;
height: $height;
position: relative;
margin: $slider-size / 2 auto 0 auto;
label {
display: none;
}
input[type='range'] {
width: 100%;
position: absolute;
left: 0;
-webkit-appearance: none;
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
transform-origin: center center;
transform: translateY(-$slider-size / 2 + $line-height / 2);
transition: box-shadow $long-animation-time, transform $long-animation-time;
@media (min-width: $mobile-width) {
&:hover {
box-shadow: $shadow;
transform: translateY(-$slider-size / 2 + $line-height / 2) scale(1.1);
}
}
cursor: pointer;
position: relative;
z-index: 2;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-focus-outer {
border: 0;
}
}
.value-container {
@include small-text();
display: flex;
justify-content: space-evenly;
span {
display: block;
margin-top: 10px;
}
}
}

View file

@ -1,107 +1,233 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { range } from '../../../utils/range';
import { Range } from '../../../interfaces/range';
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
untracked,
} from '@angular/core';
export interface DoubleSliderRange<T> {
from: T;
to: T;
}
/**
* Two-thumb range slider legacy "double-slider".
* Hands an indexed range over an arbitrary values array; emits the
* underlying values on each change. Labels magnetically lift as a thumb
* approaches them (rotated -45°), per the legacy.
*/
@Component({
selector: 'app-double-slider',
templateUrl: './double-slider.component.html',
styleUrls: ['./double-slider.component.scss']
selector: 'lt-double-slider',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="container">
<label for="ds-1">From</label>
<label for="ds-2">To</label>
<input
id="ds-1"
type="range"
min="0"
[max]="MAX - 1"
[value]="oneValue()"
(input)="oneValue.set(+$any($event.target).value)"
/>
<input
id="ds-2"
type="range"
min="0"
[max]="MAX - 1"
[value]="otherValue()"
(input)="otherValue.set(+$any($event.target).value)"
/>
<div class="value-container">
@for (i of drawnIndices(); track i) {
<span [style.transform]="getOffset(i)">{{ drawnLabels()[i] }}</span>
}
</div>
</div>
`,
styles: `
@import '../../../../library/main';
$line-height: 2px;
$height: 90px;
$slider-size: 40px;
.container {
width: 100%;
max-width: 800px;
height: $height;
position: relative;
margin: calc(#{$slider-size} / 2) auto 0 auto;
label { display: none; }
input[type='range'] {
width: 100%;
position: absolute;
left: 0;
-webkit-appearance: none;
appearance: none;
outline: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
box-shadow: $shadow-border;
transform-origin: center center;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2));
transition: box-shadow $long-animation-time, transform $long-animation-time;
@media (min-width: $mobile-width) {
&:hover {
box-shadow: $shadow;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2))
scale(1.1);
}
}
cursor: pointer;
position: relative;
z-index: 2;
}
&::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
border: none;
box-shadow: $shadow-border;
cursor: pointer;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-range-track {
-moz-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-focus-outer { border: 0; }
}
.value-container {
font-family: $normal-font;
color: $text-color;
font-size: var(--medium-font-size);
display: flex;
justify-content: space-evenly;
margin-top: calc(#{$slider-size} + 8px);
span {
display: block;
margin-top: 14px;
transform-origin: center bottom;
transition: transform $long-animation-time;
white-space: nowrap;
}
}
}
`,
})
export class DoubleSliderComponent {
@Input() labels: string[];
/** Ordered list of underlying values (e.g. dates). */
readonly values = input.required<unknown[]>();
/** Display labels for evenly-spaced ticks (≤ values.length). */
readonly labels = input.required<string[]>();
@Input() set values(values: any[]) {
if (values.length === 0) {
return;
}
this._values = values;
this.calculateLabels();
if (this._oneValue > this._otherValue) {
this._oneValue = this.MAX - 1;
} else {
this._otherValue = this.MAX - 1;
}
this.emitValue();
}
get values(): any[] {
return this._values;
}
get oneValue(): number {
return this._oneValue;
}
set oneValue(value: number) {
this._oneValue = value;
this.emitValue();
}
get otherValue(): number {
return this._otherValue;
}
set otherValue(value: number) {
this._otherValue = value;
this.emitValue();
}
private _values: any[];
@Output() range: EventEmitter<Range<any>> = new EventEmitter();
drawnLabels: string[];
readonly rangeChange = output<DoubleSliderRange<unknown>>();
readonly MAX = 100;
private _oneValue = 0;
readonly oneValue = signal(0);
readonly otherValue = signal(this.MAX - 1);
private _otherValue: number = this.MAX - 1;
private prevValuesLength = 0;
drawnLabelsIndices: Iterable<number>;
readonly drawnLabels = computed(() => {
const labels = this.labels();
const count = Math.min(labels.length, 6);
if (count === 0) return [] as string[];
const jump = Math.max(1, Math.round(labels.length / count));
return labels.filter((_, i) => i % jump === 0);
});
private calculateLabels() {
const labelCount = 6;
const jumpLength = Math.round(this.labels.length / labelCount);
this.drawnLabels = this.labels.filter((_, index) => index % jumpLength === 0);
this.drawnLabelsIndices = range({ max: this.drawnLabels.length });
readonly drawnIndices = computed(() =>
Array.from({ length: this.drawnLabels().length }, (_, i) => i),
);
constructor() {
// Re-emit the value range whenever the slider thumbs or values change.
effect(() => {
const a = this.oneValue();
const b = this.otherValue();
const vs = this.values();
if (vs.length === 0) return;
const lo = Math.min(a, b);
const hi = Math.max(a, b);
untracked(() => {
this.rangeChange.emit({
from: vs[this.indexFromValue(lo)],
to: vs[this.indexFromValue(hi)],
});
});
});
// Snap the higher thumb to MAX - 1 when a new entry is appended.
effect(() => {
const len = this.values().length;
untracked(() => {
if (len > this.prevValuesLength) {
const a = this.oneValue();
const b = this.otherValue();
if (a > b) this.oneValue.set(this.MAX - 1);
else this.otherValue.set(this.MAX - 1);
}
this.prevValuesLength = len;
});
});
}
private indexFromValue(value: number): number {
return Math.floor((value / this.MAX) * this.values.length);
return Math.min(
this.values().length - 1,
Math.floor((value / this.MAX) * this.values().length),
);
}
/**
* Magnetic label position: returns a CSS `transform` that lifts the label
* upward and rotates -45° as a thumb approaches.
*/
getOffset(index: number): string {
const labelIndex = index / this.drawnLabels.length;
const slider1Index = this.oneValue / this.MAX - 0.1;
const slider2Index = this.otherValue / this.MAX - 0.1;
const dist = (a, b) => Math.abs(a - b);
const labelSliderDistance = Math.min(dist(labelIndex, slider1Index), dist(labelIndex, slider2Index));
const labelIndex = index / Math.max(1, this.drawnLabels().length);
const a = this.oneValue() / this.MAX - 0.1;
const b = this.otherValue() / this.MAX - 0.1;
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
const ACTIVE_ZONE = 0.2;
const BASE_TRANSFORM = 'translateX(-50%) rotate(-45deg) translateY(100%)';
if (labelSliderDistance > ACTIVE_ZONE) {
return BASE_TRANSFORM;
}
return `translateY(${Math.pow((ACTIVE_ZONE - labelSliderDistance) / ACTIVE_ZONE, 1) * 30}px) ${BASE_TRANSFORM}`;
}
private emitValue() {
this.range.emit({
from:
this.oneValue < this.otherValue
? this.values[this.indexFromValue(this.oneValue)]
: this.values[this.indexFromValue(this.otherValue)],
to:
this.oneValue < this.otherValue
? this.values[this.indexFromValue(this.otherValue)]
: this.values[this.indexFromValue(this.oneValue)]
});
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
if (dist > ACTIVE_ZONE) return base;
const lift = ((ACTIVE_ZONE - dist) / ACTIVE_ZONE) * 36;
return `translateY(${lift}px) ${base}`;
}
}

View file

@ -0,0 +1,29 @@
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
export type IconName = 'arrow' | 'pen' | 'plus-sign' | 'trash' | 'x-sign';
@Component({
selector: 'lt-icon',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<img
[src]="'assets/' + name() + '.svg'"
[alt]="name()"
[style.width]="size()"
[style.height]="size()"
aria-hidden="true"
/>
`,
styles: `
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
`,
})
export class IconComponent {
readonly name = input.required<IconName>();
readonly size = input<string>('1.25rem');
}

View file

@ -1,45 +0,0 @@
<div class="select-add {{ onlyShadowBorder ? 'shadow-border' : '' }}" (click)="$event.stopPropagation()">
<div #top class="top" (click)="!editMode && toggle()">
<p [innerHTML]="selected ? selected : placeholder" *ngIf="!editMode || !selected; else editableSelected"></p>
<ng-template #editableSelected>
<input type="text" [value]="selected" (change)="changeOption(selected, $event)" />
</ng-template>
<img src="assets/arrow.svg" (click)="onArrowClick($event)" [className]="isOpen ? 'upside-down' : ''" alt="arrow" />
</div>
<div class="bottom-container">
<div #bottom class="bottom {{ isOpen ? 'open' : '' }}">
<ng-container *ngIf="!editMode; else editableOthers">
<p *ngFor="let option of otherOptions" [innerHTML]="option" (click)="select(option)"></p>
</ng-container>
<ng-template #editableOthers>
<input
type="text"
*ngFor="let option of otherOptions"
[value]="option"
(change)="changeOption(option, $event)"
/>
</ng-template>
<input
type="text"
*ngIf="options.length <= maxItemCount"
[placeholder]="newValuePlaceholder"
[(ngModel)]="newOption"
(keyup)="handleKeys($event)"
/>
<div class="buttons">
<button *ngIf="options.length <= maxItemCount" (click)="addNewOption()" [disabled]="!newOption">Add</button>
<div *ngIf="editable" class="edit {{ editMode ? 'active' : '' }}" (click)="editMode = !editMode">
<img src="assets/pen.svg" alt="edit" />
</div>
</div>
</div>
</div>
<div
class="background {{ isOpen || alwaysDropShadow ? 'active' : '' }}"
[ngStyle]="{ height: backgroundHeight }"
></div>
</div>

View file

@ -1,189 +0,0 @@
@import '../../../../styles';
$inner-padding: var(--medium-padding);
.select-add {
width: 100%;
position: relative;
.top,
.bottom {
padding: $inner-padding;
z-index: 4;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
cursor: pointer;
p,
input[type='text'] {
display: inline-block;
@include sub-title-text();
}
img {
@include square(16px);
transition: transform $long-animation-time;
&.upside-down {
transform: rotate(-180deg);
}
}
}
.bottom-container {
width: 100%;
height: 300px;
position: absolute;
overflow-y: hidden;
pointer-events: none;
.bottom {
position: absolute;
width: 100%;
pointer-events: all;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 0 0 var(--border-radius) var(--border-radius);
padding-top: 0;
@include inner-spacing($inner-padding);
transform: translateY(-100%);
visibility: hidden;
&.open {
visibility: visible;
transform: none;
}
transition: transform $long-animation-time;
p {
@include sub-title-text();
display: inline-block;
text-align: left;
cursor: pointer;
}
.buttons {
height: 32px;
@media (max-width: $mobile-width) {
height: 24px;
}
position: relative;
width: 100%;
button {
margin: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
}
.edit {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
margin: 0;
opacity: 0.25;
cursor: pointer;
img {
@include square(16px);
}
transition: opacity $short-animation-time;
&:before {
content: '';
display: block;
position: absolute;
bottom: calc(-1 * #{$line-height});
left: 0;
height: $line-height;
background-color: $text-color;
width: 0;
transition: width $long-animation-time;
}
@media (min-width: $mobile-width) {
&:hover {
opacity: 0.5;
}
&:hover {
&:before {
width: 100% !important;
}
}
}
&.active {
&:before {
width: 100% !important;
}
}
&.active {
opacity: 1;
}
}
}
}
.edit {
}
}
.background {
position: absolute;
top: 0;
height: 100%;
width: 100%;
@include card();
z-index: 3;
transition: box-shadow $long-animation-time, height $long-animation-time;
&.active {
box-shadow: $shadow;
}
}
&:hover {
@media (min-width: $mobile-width) {
.background {
box-shadow: $shadow;
}
}
}
&.shadow-border {
.background.active {
box-shadow: $shadow-border;
}
}
&.shadow-border:hover {
.background {
box-shadow: $shadow-border;
}
}
}

View file

@ -1,107 +1,357 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core';
import { CancelService } from '../../../services/cancel.service';
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
OnChanges,
SimpleChanges,
} from '@angular/core';
@Component({
selector: 'app-select-add',
templateUrl: './select-add.component.html',
styleUrls: ['./select-add.component.scss']
selector: 'lt-select-add',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="container"
[class.shadow-border]="onlyShadowBorder()"
[class.always-shadow]="alwaysDropShadow()"
>
<div class="background" [class.active]="open()"></div>
<div class="top" (click)="open.update(v => !v)">
<p>{{ resolvedSelected() ?? placeholder() }}</p>
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
</div>
<div class="bottom-container">
<div class="bottom" [class.open]="open()">
@for (item of resolvedItems(); track item) {
@if (editing()) {
<input
type="text"
[value]="item"
(blur)="onRename(item, $any($event.target).value)"
/>
} @else {
<p (click)="onSelectItem(item)">{{ item }}</p>
}
}
<div class="add-row">
<input
type="text"
#addInput
placeholder="Add a value…"
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
/>
<button (click)="onAdd(addInput.value); addInput.value = ''">Add</button>
@if (editable()) {
<button class="pen" [class.active]="editing()" (click)="editing.update(v => !v)">
<img src="assets/pen.svg" alt="Edit" />
</button>
}
</div>
</div>
</div>
</div>
`,
styles: `
@import '../../../../library/main';
$inner-padding: var(--medium-padding);
:host {
display: block;
width: 100%;
}
.container {
width: 100%;
position: relative;
.top,
.bottom {
padding: $inner-padding;
z-index: 4;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
cursor: pointer;
p {
display: inline-block;
@include sub-title-text();
}
img.arrow {
@include square(16px);
transition: transform $long-animation-time;
&.upside-down {
transform: rotate(-180deg);
}
}
}
.bottom-container {
width: 100%;
height: 300px;
position: absolute;
overflow-y: hidden;
pointer-events: none;
.bottom {
position: absolute;
width: 100%;
pointer-events: all;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 0 0 var(--border-radius) var(--border-radius);
padding: $inner-padding;
padding-top: 0;
@include inner-spacing($inner-padding);
// Default (closed) state — also the target of the close transition.
background-color: transparent;
box-shadow: none;
transform: translateY(-100%);
visibility: hidden;
// Delay the visibility change until after the slide animation finishes
// so the panel stays visible while it animates closed.
transition:
transform $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s $long-animation-time;
&.open {
visibility: visible;
transform: none;
background-color: $light-color;
box-shadow: $shadow;
// Show shadow on left/right/bottom only; clip the top edge so the
// shadow doesn't bleed over the seam where .bottom meets .top.
clip-path: inset(0 -6px -6px -6px);
// On open, visibility flips immediately (no delay); transform +
// colors + shadow animate over $long-animation-time.
transition:
transform $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s 0s;
}
p {
@include sub-title-text();
display: inline-block;
text-align: left;
cursor: pointer;
}
input[type='text'] {
@include sub-title-text();
width: 100%;
}
.add-row {
height: 32px;
@media (max-width: $mobile-width) { height: 24px; }
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: var(--small-padding);
input[type='text'] {
flex: 1;
}
button {
margin: 0;
position: static;
&.pen {
opacity: 0.25;
cursor: pointer;
display: flex;
align-items: center;
padding: 0;
border: none;
background: transparent;
position: relative;
img {
@include square(16px);
}
&:before {
content: '';
display: block;
position: absolute;
bottom: -2px;
left: 0;
height: 2px;
background-color: $text-color;
width: 0;
transition: width $long-animation-time;
}
@media (min-width: $mobile-width) {
&:hover { opacity: 0.5; }
&:hover:before { width: 100% !important; }
}
&.active {
opacity: 1;
&:before { width: 100% !important; }
}
transition: opacity $short-animation-time;
}
}
}
}
}
.background {
position: absolute;
top: 0;
height: 100%;
width: 100%;
@include card();
z-index: 3;
transition:
box-shadow $long-animation-time,
height $long-animation-time,
border-radius $long-animation-time;
&.active {
box-shadow: $shadow;
// Show shadow on top/left/right only; clip the bottom edge so the
// shadow doesn't bleed over the seam where .top meets .bottom.
clip-path: inset(-6px -6px 0 -6px);
}
}
.top {
transition: border-radius $long-animation-time;
}
// When the drawer is open, square the BOTTOM corners on both the
// background card and the top chip so they merge into one continuous
// surface. (Animated via the border-radius transitions above.)
&:has(.bottom.open) {
.top,
.background {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
}
&:hover {
@media (min-width: $mobile-width) {
.background { box-shadow: $shadow; }
}
}
&.shadow-border {
.background.active {
box-shadow: $shadow-border;
clip-path: inset(-6px -6px 0 -6px);
}
}
&.shadow-border:hover {
.background { box-shadow: $shadow-border; }
}
&.always-shadow {
.background { box-shadow: $shadow; }
// When open, clip the bottom so the always-on shadow doesn't bleed
// over the seam; restore full shadow when closed.
&:has(.bottom.open) .background {
clip-path: inset(-6px -6px 0 -6px);
}
}
}
`,
})
export class SelectAddComponent {
@Input() placeholder = 'Add a new value…';
@Input() newValuePlaceholder = 'Add a value…';
@Input() maxItemCount = 7;
@Input() options: string[];
@Input() alwaysDropShadow = false;
@Input() onlyShadowBorder = false;
@Input() editable = false;
export class SelectAddComponent implements OnChanges {
// ── New API (spec) ─────────────────────────────────────────────────────────
/** List of string options */
readonly items = input<string[]>([]);
/** Currently selected string value */
readonly selected = input<string | null>(null);
readonly editable = input<boolean>(false);
readonly placeholder = input<string>('Select…');
readonly alwaysDropShadow = input<boolean>(false);
readonly onlyShadowBorder = input<boolean>(false);
@Input() set default(value: string) {
this.selected = value;
// ── Legacy compat API (used by pages.component.html until Agent B updates) ─
/** @deprecated Use items instead */
readonly options = input<string[]>([]);
/** @deprecated Use selected (string) instead */
readonly selectedIndex = input<number>(-1);
// ── Outputs — new API ──────────────────────────────────────────────────────
readonly select = output<string>();
readonly add = output<string>();
readonly rename = output<{ old: string; new: string }>();
readonly remove = output<string>();
// ── Legacy compat outputs ──────────────────────────────────────────────────
/** @deprecated Use select instead */
readonly selectionChange = output<number>();
// ── Internal state ─────────────────────────────────────────────────────────
readonly open = signal(false);
readonly editing = signal(false);
// Resolved values that merge old + new API
protected resolvedItems(): string[] {
const newItems = this.items();
const oldOptions = this.options();
return newItems.length ? newItems : oldOptions;
}
backgroundHeight: string;
private _editMode = false;
set editMode(value: boolean) {
this._editMode = value;
this.backgroundHeight = this.getBackgroundHeight();
protected resolvedSelected(): string | null {
// New API: string
const s = this.selected();
if (s != null) return s;
// Legacy API: index into options
const idx = this.selectedIndex();
const opts = this.resolvedItems();
if (idx >= 0 && idx < opts.length) return opts[idx];
return null;
}
get editMode(): boolean {
return this._editMode;
ngOnChanges(_changes: SimpleChanges): void {
// Nothing to do — signals handle reactivity
}
@Output() value: EventEmitter<string> = new EventEmitter();
@Output() optionChange: EventEmitter<{ from: string; to: string }> = new EventEmitter();
@ViewChild('top') top: ElementRef;
@ViewChild('bottom') bottom: ElementRef;
selected: string;
newOption: string;
isOpen = false;
constructor(private cancelService: CancelService, private changeDetection: ChangeDetectorRef) {
this.cancelService.subscribe(this, () => {
this.isOpen = false;
this.editMode = false;
this.changeDetection.markForCheck();
});
onSelectItem(item: string): void {
this.select.emit(item);
// Legacy compat: also emit the index
const idx = this.resolvedItems().indexOf(item);
if (idx >= 0) this.selectionChange.emit(idx);
this.open.set(false);
this.editing.set(false);
}
changeOption(from: string, event) {
// console.log(event);
this.optionChange.emit({
from,
to: event.target.value
});
onAdd(value: string): void {
const v = value.trim();
if (!v) return;
this.add.emit(v);
this.open.set(false);
}
get otherOptions(): string[] {
return this.options.filter(a => a !== this.selected);
}
handleKeys(event: KeyboardEvent) {
if (event.key === 'Enter') {
this.addNewOption();
onRename(oldValue: string, newValue: string): void {
const n = newValue.trim();
if (n && n !== oldValue) {
this.rename.emit({ old: oldValue, new: n });
}
}
addNewOption() {
if (this.newOption) {
this.select(this.newOption);
this.newOption = '';
}
}
select(option: string) {
this.selected = option;
this.value.emit(this.selected);
this.toggle();
}
toggle() {
this.isOpen = !this.isOpen;
if (!this.isOpen) {
this.editMode = false;
}
this.backgroundHeight = this.getBackgroundHeight();
}
onArrowClick(event) {
if (this.editMode) {
this.toggle();
event.stopPropagation();
}
}
private getBackgroundHeight(): string {
if (this.isOpen && this.top && this.bottom) {
const topHeight = this.top.nativeElement.clientHeight;
const bottomHeight = this.bottom.nativeElement.clientHeight;
// console.log(topHeight, bottomHeight);
return `${topHeight + bottomHeight}px`;
}
return `100%`;
}
}

View file

@ -1,7 +0,0 @@
<span [className]="!on ? 'active' : ''" (click)="on = false" [innerText]="beforeText"></span>
<label>
<input type="checkbox" [(ngModel)]="on" [className]="on ? 'on' : ''" />
</label>
<span [className]="on ? 'active' : ''" (click)="on = true" [innerText]="afterText"></span>

View file

@ -1,75 +0,0 @@
@import '../../../../styles';
:host {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
span {
@include medium-text();
max-width: 3 * $size;
cursor: pointer;
&.active {
font-weight: bold;
}
&:first-of-type {
text-align: right;
}
&:last-of-type {
text-align: left;
}
}
label {
display: block;
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
width: 2 * $size;
height: $size;
border-radius: 1000px;
box-shadow: $shadow-border;
position: relative;
cursor: pointer;
&:after {
content: '';
position: absolute;
display: block;
left: 0;
@include square($size);
border-radius: 1000px;
background-color: $text-color;
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
}
&.on:after {
left: $size;
}
}
input[type='checkbox'] {
@media (min-width: $mobile-width) {
&:hover:after {
box-shadow: $shadow;
transform: translateX(2px);
}
&.on:hover:after {
transform: translateX(-2px);
}
}
}
}
}

View file

@ -1,27 +1,105 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
input,
model,
} from '@angular/core';
@Component({
selector: 'app-toggle',
templateUrl: './toggle.component.html',
styleUrls: ['./toggle.component.scss']
selector: 'lt-toggle',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toggle">
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
<label>
<input
type="checkbox"
[class.on]="checked()"
[checked]="checked()"
(change)="set(!checked())"
/>
</label>
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
</div>
`,
styles: `
@import '../../../../library/main';
:host {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
.toggle {
display: contents;
}
span {
@include medium-text();
// Fixed width (not max-width) so multiple toggles align column-wise
// — the thumb position is identical across rows regardless of label.
flex: 0 0 auto;
width: 4 * $size;
box-sizing: border-box;
padding: 0 var(--small-padding);
line-height: 1.3;
cursor: pointer;
&.active { font-weight: bold; }
&:first-of-type { text-align: right; }
&:last-of-type { text-align: left; }
}
label {
display: block;
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
width: 2 * $size;
height: $size;
border-radius: 1000px;
box-shadow: $shadow-border;
position: relative;
cursor: pointer;
&:after {
content: '';
position: absolute;
display: block;
left: 0;
@include square($size);
border-radius: 1000px;
background-color: $text-color;
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
}
&.on:after { left: $size; }
}
input[type='checkbox'] {
@media (min-width: $mobile-width) {
&:hover:after {
box-shadow: $shadow;
transform: translateX(2px);
}
&.on:hover:after {
transform: translateX(-2px);
}
}
}
}
}
`,
})
export class ToggleComponent {
@Input() beforeText: string;
@Input() afterText: string;
readonly checked = model<boolean>(false);
readonly offLabel = input<string>('No');
readonly onLabel = input<string>('Yes');
@Output() value: EventEmitter<boolean> = new EventEmitter();
@Input() set default(value: boolean) {
this._on = value;
}
private _on = false;
set on(value: boolean) {
this._on = value;
this.value.emit(value);
}
get on(): boolean {
return this._on;
set(value: boolean): void {
this.checked.set(value);
}
}

View file

@ -0,0 +1,192 @@
import { Component, ChangeDetectionStrategy, input, output, signal, effect, untracked } from '@angular/core';
import { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color';
/**
* Tasks accordion shows pending (not-done) blocks inside a tower.
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses.
* Clicking the colored tickbox marks the task done.
* Clicking the description opens the block-edit modal via the `edit` output.
*/
@Component({
selector: 'lt-tasks',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="container"
[class.show-hover]="pending().length > 0"
(click)="expanded.update(v => !v)"
>
<p class="header">
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
</p>
<div
class="all-task"
#all
[style.height.px]="expanded() ? all.scrollHeight : 0"
>
@for (b of pending(); track b.id) {
<div class="task-container">
<button
type="button"
class="tickbox"
[style.background-color]="colorOf(b.tag)"
(click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button>
<p
[style.color]="colorOf(b.tag)"
(click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</p>
</div>
}
</div>
</div>
`,
styles: `
@import '../../../library/main';
:host {
width: 100%;
box-sizing: border-box;
position: relative;
// Within the tower stacking context: high enough to float above the
// falling-blocks layer. Globally low enough that modals + the carousel
// (10000+) always cover us.
z-index: 5;
.container {
@include card();
cursor: pointer;
transition: box-shadow $long-animation-time;
&.show-hover:hover {
box-shadow: $shadow-border;
}
padding: calc(var(--small-padding) / 2);
margin: calc(var(--small-padding) / 2);
max-height: 30vh;
overflow-y: auto;
.header { cursor: pointer; }
p { font-size: var(--medium-font-size); }
.all-task {
@include inner-spacing(var(--small-padding));
:first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
.task-container {
display: flex;
align-items: center;
gap: var(--small-padding);
&:hover p {
@media (min-width: $mobile-width) {
color: inherit !important;
}
}
// Tickbox: a generously sized colored button that marks the task
// done without opening the edit carousel. Hover & focus reveal a
// subtle inner check mark.
.tickbox {
flex: 0 0 auto;
all: unset; // strip native button styles
cursor: pointer;
position: relative;
box-sizing: border-box;
@include square(24px);
@media (max-width: $mobile-width) {
@include square(20px);
}
border-radius: 4px;
box-shadow: $shadow-border;
transition: transform $short-animation-time, box-shadow $long-animation-time;
&::after {
content: '✓';
position: absolute;
inset: 0;
@include center-child();
color: $light-color;
font-size: 18px;
font-weight: bold;
line-height: 1;
opacity: 0.5;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
transform: translateY(2px);
transition: opacity $short-animation-time, transform $short-animation-time;
}
&:hover,
&:focus-visible {
box-shadow: $shadow;
transform: scale(1.05);
&::after { opacity: 0.85; }
}
&:active {
transform: scale(0.95);
&::after { opacity: 1; transform: translateY(2px) scale(1.05); }
}
}
p {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
flex: 1 1 auto;
cursor: pointer;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
}
position: relative;
}
}
}
}
}
`,
})
export class TasksComponent {
readonly pending = input.required<Block[]>();
readonly baseColor = input.required<HslColor>();
/** When true, the accordion starts expanded on first render. */
readonly initiallyOpen = input<boolean>(false);
/** Emitted when the colored tickbox is clicked — parent flips is_done to true. */
readonly markDone = output<Block>();
/** Emitted when the description is clicked — parent opens the block-edit modal. */
readonly edit = output<Block>();
readonly expanded = signal(false);
constructor() {
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
// the "Keep tasks open" page setting expands/collapses the accordion live.
// User clicks (which mutate `expanded` directly) are respected until the
// setting changes again.
effect(() => {
const open = this.initiallyOpen();
untracked(() => this.expanded.set(open));
});
}
colorOf(tag: string): string {
return getColorOfTag(tag, this.baseColor());
}
}

View file

@ -0,0 +1,469 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
untracked,
} from '@angular/core';
import { Tower, Block } from '../../models';
import { BlockComponent } from '../block/block.component';
import { TasksComponent } from '../tasks/tasks.component';
import { BlockEditComponent, BlockEditSave } from '../modal/block-edit.component';
import { ModalComponent } from '../modal/modal.component';
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
import { toCss } from '../../utils/color';
/** Tracks which entry path the block-edit modal was opened from. */
type EditEntry = { filter: 'done' | 'pending'; activeId: string | null };
/** A done block augmented with per-render animation state. */
interface StyledBlock extends Block {
_anim: '' | 'descend' | 'ascend';
_transform: string;
_opacity: string;
}
@Component({
selector: 'lt-tower',
standalone: true,
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tower">
<div class="container" [class.trash-highlight]="trashHighlight()">
<lt-tasks
[pending]="pending()"
[baseColor]="tower().base_color"
[initiallyOpen]="keepTasksOpen()"
(markDone)="onMarkTaskDone($event)"
(edit)="onEditBlock($event)"
/>
<img
src="assets/plus-sign.svg"
class="add-block"
alt="Add block"
(click)="$event.stopPropagation(); openAddBlock()"
/>
<div class="block-container-container">
<div class="block-container">
@for (b of visibleBlocks(); track b.id) {
<lt-block
[block]="b"
[baseColor]="tower().base_color"
[class]="b._anim"
[style.transform]="b._transform"
[style.opacity]="b._opacity"
(clicked)="onEditBlock(b)"
/>
}
</div>
</div>
</div>
<input
#nameInput
type="text"
[value]="tower().name"
[style.color]="towerNameCss()"
(blur)="onRename($event)"
(keydown.enter)="nameInput.blur()"
/>
</div>
@if (editEntry(); as entry) {
<lt-modal (close)="closeEdit()">
<lt-block-edit
[blocks]="filteredForEntry()"
[activeBlockId]="entry.activeId"
[tags]="towerTags()"
[baseColor]="tower().base_color"
[defaultDone]="entry.filter === 'done'"
(save)="onBlockSave($event)"
(delete)="onBlockDelete($event)"
(close)="closeEdit()"
/>
</lt-modal>
}
@if (showSettings()) {
<lt-modal (close)="showSettings.set(false)">
<lt-tower-settings [tower]="tower()" (save)="onTowerSave($event)" (delete)="onTowerDelete()" />
</lt-modal>
}
`,
styles: `
@import '../../../library/main';
:host {
cursor: pointer;
&.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
&.cdk-drag-placeholder {
opacity: 0;
}
&:hover {
@media (min-width: $mobile-width) {
div.container {
box-shadow: $shadow;
}
}
}
&.cdk-drag-preview {
div.container {
@media (max-width: $mobile-width) {
@keyframes shadow {
from { box-shadow: none; }
to { box-shadow: $shadow; }
}
animation: shadow $long-animation-time forwards;
}
}
}
&.trash-highlight {
.container {
transform: scale(0.75);
position: relative;
:before {
opacity: 0.5 !important;
}
}
input {
display: none;
}
}
.tower {
display: flex;
flex-direction: column;
align-items: center;
max-width: 100%;
height: 100%;
@include inner-spacing(var(--small-padding));
.container {
display: flex;
flex-direction: column;
flex: 1 1 auto;
position: relative;
@include card();
overflow: hidden;
transition: transform $short-animation-time, box-shadow $long-animation-time;
@include inner-spacing(var(--medium-padding));
width: 100%;
:before {
content: '';
pointer-events: none;
position: absolute;
z-index: 2;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: red;
opacity: 0;
border-radius: var(--border-radius);
transition: opacity $short-animation-time;
}
lt-tasks {
flex: 0 0 auto;
min-height: 56px;
max-height: 30vh;
overflow: hidden;
display: block;
width: 100%;
.container {
max-height: 100%;
overflow-y: auto;
}
}
img.add-block {
flex: 0 0 auto;
align-self: center;
margin: var(--medium-padding) 0;
}
img {
position: relative;
z-index: 2;
height: 48px;
@media (max-width: $mobile-width) {
height: 32px;
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.block-container-container {
position: relative;
flex: 1;
.block-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-end;
position: absolute;
bottom: 0;
width: 100%;
transform: scaleY(-1);
/* Default resting position for all blocks before JS sets them */
* {
transform: translateY(500%);
}
.descend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
}
.ascend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
}
}
}
}
input[type='text'] {
font-size: var(--small-font-size);
text-align: center;
@media (min-width: $mobile-width) {
width: 50%;
}
}
}
}
`,
})
export class TowerComponent {
// ── Inputs ─────────────────────────────────────────────────────────────────
readonly tower = input.required<Tower>();
/** Set by page component when this tower is being dragged over trash. */
readonly trashHighlight = input<boolean>(false);
/** Optional date range filter when set, blocks with `created_at`
* outside [from, to] are hidden from the falling stack. */
readonly dateRange = input<{ from: number; to: number } | null>(null);
/** When true, the tasks accordion starts expanded on load. */
readonly keepTasksOpen = input<boolean>(false);
// ── Outputs ────────────────────────────────────────────────────────────────
readonly updateTower = output<TowerSettingsResult>();
readonly deleteTowerRequest = output<void>();
/** Emitted when a new block is created from the carousel's "Create now" card. */
readonly addBlock = output<{ tag: string; description: string; is_done: boolean }>();
/** Emitted when an existing block is patched from the carousel. */
readonly saveBlock = output<{
blockId: string;
result: { tag: string; description: string; is_done: boolean };
}>();
readonly deleteBlock = output<string>();
// ── UI state ───────────────────────────────────────────────────────────────
/** The single source of truth for "block-edit modal open" encodes both
* which list of blocks to show and which one to focus initially. */
readonly editEntry = signal<EditEntry | null>(null);
readonly showSettings = signal(false);
// ── Derived ────────────────────────────────────────────────────────────────
/** Pending (not-done) blocks — fed to the tasks accordion. */
readonly pending = computed(() => this.tower().blocks.filter(b => !b.is_done));
/** CSS color string for the tower name input. */
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
/** Filtered list passed to the block-edit carousel. */
readonly filteredForEntry = computed(() => {
const entry = this.editEntry();
if (!entry) return [];
const isDone = entry.filter === 'done';
return this.tower().blocks.filter(b => b.is_done === isDone);
});
/** Unique tags from existing blocks of this tower. */
readonly towerTags = computed(() => {
const set = new Set<string>();
for (const b of this.tower().blocks) if (b.tag) set.add(b.tag);
return [...set];
});
// ── Falling animation ──────────────────────────────────────────────────────
// Same approach as the legacy: detect "exactly one done block was added"
// and snap that last block to translateY(500%)/opacity:0, then on next
// tick flip it back to translateY(0)/opacity:1 with .descend so the
// 1.5s gravity transition fires.
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
readonly visibleBlocks = this._visibleBlocks.asReadonly();
private prevDoneIds: string[] = [];
private isFirstRun = true;
constructor() {
effect(() => {
const range = this.dateRange();
// Always keep ALL done blocks in the visible list. The date filter
// only flips per-block `_anim` so the 1.5s ascend/descend transition
// can animate them in and out of the falling stack.
const allDone = this.tower().blocks.filter((b) => b.is_done);
untracked(() => this.reconcile(allDone, range));
});
}
private reconcile(allDone: Block[], range: { from: number; to: number } | null): void {
const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds;
const prevSet = new Set(prev);
const newIds = ids.filter(id => !prevSet.has(id));
const grewByOne =
!this.isFirstRun &&
ids.length === prev.length + 1 &&
newIds.length === 1 &&
prev.every(id => ids.includes(id)); // no IDs disappeared
const inRange = (b: Block) =>
!range || (b.created_at >= range.from && b.created_at <= range.to);
const styled: StyledBlock[] = [];
for (const b of allDone) {
if (range && b.created_at < range.from) {
// Below min-thumb boundary → drop entirely (instant shuffle, no animation).
continue;
}
if (range && b.created_at > range.to) {
// Above max-thumb boundary → fly up off the tower with gravity animation.
styled.push({
...b,
_anim: 'ascend',
_transform: 'translateY(500%)',
_opacity: '0',
});
continue;
}
// In range — descend into position (or appear instantly on first run).
styled.push({
...b,
_anim: this.isFirstRun ? '' : 'descend',
_transform: 'translateY(0)',
_opacity: '1',
});
}
if (grewByOne) {
const newId = newIds[0];
const newBlock = styled.find(b => b.id === newId);
if (newBlock && inRange(newBlock)) {
// Snap newly-added in-range block to start position, then on the next
// paint flip it back to rest — that's what makes it visibly fall.
newBlock._anim = '';
newBlock._transform = 'translateY(500%)';
newBlock._opacity = '0';
this._visibleBlocks.set(styled);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
newBlock._anim = 'descend';
newBlock._transform = 'translateY(0)';
newBlock._opacity = '1';
this._visibleBlocks.set([...this._visibleBlocks()]);
});
});
this.prevDoneIds = ids;
this.isFirstRun = false;
return;
}
}
// existing fall-through path (no growth, first run, or new block out of range):
this._visibleBlocks.set(styled);
this.prevDoneIds = ids;
this.isFirstRun = false;
}
// ── Event handlers ─────────────────────────────────────────────────────────
onRename(event: Event): void {
const input = event.target as HTMLInputElement;
const newName = input.value.trim();
if (newName && newName !== this.tower().name) {
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
}
}
onEditBlock(block: Block): void {
this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id });
}
/** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */
onMarkTaskDone(block: Block): void {
this.saveBlock.emit({
blockId: block.id,
result: { tag: block.tag, description: block.description, is_done: true },
});
}
/** Called by the template "Add block" plus-icon. */
openAddBlock(): void {
this.editEntry.set({ filter: 'done', activeId: null });
}
closeEdit(): void {
this.editEntry.set(null);
}
onBlockSave(ev: BlockEditSave): void {
if (ev.id === null) {
this.addBlock.emit({ tag: ev.tag, description: ev.description, is_done: ev.is_done });
} else {
this.saveBlock.emit({
blockId: ev.id,
result: { tag: ev.tag, description: ev.description, is_done: ev.is_done },
});
}
}
onBlockDelete(id: string): void {
// Don't close the carousel — the deleted block disappears from `blocks()`
// and the carousel re-renders in place. The user keeps editing siblings.
this.deleteBlock.emit(id);
}
onTowerSave(result: TowerSettingsResult): void {
this.showSettings.set(false);
this.updateTower.emit(result);
}
onTowerDelete(): void {
this.showSettings.set(false);
this.deleteTowerRequest.emit();
}
}

View file

@ -0,0 +1,88 @@
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
@Component({
selector: 'lt-welcome',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="welcome-card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Welcome to Life Towers</h2>
<p class="lead">
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project).
Each <strong>tower</strong> is a stack of related tasks. As you finish a task, it falls into the
tower as a colored square the more you do, the taller it grows.
</p>
<p class="muted">
Everything you write is saved to a small remote database, keyed to a private UUID token shown
under <em>Settings Account</em>. Copy that token to recover your data on another device, or
paste a friend's token to look at theirs.
</p>
<div class="actions">
<button type="button" (click)="startFresh.emit()">Start fresh</button>
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button>
</div>
</div>
`,
styles: `
@import '../../../library/main';
:host { display: block; }
.welcome-card {
@include card();
width: 66vw;
max-width: 480px;
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
text-align: left;
@include inner-spacing(var(--medium-padding));
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
}
h2 {
text-align: center;
margin-bottom: var(--medium-padding);
}
p.lead { color: $text-color; }
p.muted {
color: rgba($text-color, 0.7);
font-size: var(--medium-font-size);
em { font-style: italic; }
}
.actions {
display: flex;
justify-content: space-around;
gap: var(--large-padding);
margin-top: var(--large-padding);
button.primary {
color: $accent-color;
border-bottom-color: rgba($accent-color, 0.33);
&:after { background-color: $accent-color; }
}
}
}
`,
})
export class WelcomeComponent {
readonly close = output<void>();
readonly startFresh = output<void>();
readonly loadExample = output<void>();
}