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 { getColorOfTag } from '../../utils/color'; export interface BlockEditSave { /** null = create a new block */ id: string | null; tag: string; description: string; is_done: boolean; difficulty: number; } interface EditedValue { tag: string; description: string; is_done: boolean; difficulty: number; } @Component({ selector: 'lt-block-edit', standalone: true, imports: [SelectAddComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (viewTitle()) {

{{ viewTitle() }}

} `, 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) @media (max-height: $min-height) { align-items: flex-start; overflow-y: auto; } } .view-title { position: fixed; top: var(--large-padding); left: var(--large-padding); right: var(--large-padding); z-index: 10002; margin: 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } .carousel { --title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size)); width: 100%; height: 100%; display: flex; align-items: center; box-sizing: border-box; padding: var(--title-clearance) 0 var(--large-padding); overflow-x: auto; overflow-y: hidden; scroll-behavior: smooth; scroll-snap-type: x mandatory; @media (max-width: $mobile-width) { padding: var(--title-clearance) var(--medium-padding) var(--medium-padding); } @media (max-height: $min-height) { min-height: max-content; align-items: flex-start; padding-top: var(--title-clearance); padding-bottom: var(--medium-padding); overflow-y: visible; } &::-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; scroll-snap-align: center; @media (max-width: $mobile-width) { width: min(88vw, 360px); max-width: calc(100vw - (2 * var(--medium-padding))); padding: var(--medium-padding); margin: 0 calc(var(--small-padding) / 2); 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; @media (max-width: $mobile-width) { width: var(--medium-padding); max-width: var(--medium-padding); min-width: var(--medium-padding); } box-shadow: none; background: transparent; } .header { @include center-child(); position: relative; gap: var(--small-padding); h1 { min-width: 0; overflow-wrap: anywhere; } .exit { position: absolute; right: 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); } } .done-checkbox { @include medium-text(); display: flex; align-items: center; justify-content: center; gap: var(--small-padding); width: max-content; max-width: 100%; margin: 0 auto var(--medium-padding); cursor: pointer; input[type='checkbox'] { -webkit-appearance: none; appearance: none; @include square(22px); flex: 0 0 auto; position: relative; box-sizing: border-box; margin: 0; border: 0; border-radius: 4px; background: $light-color; box-shadow: $shadow-border; cursor: pointer; transition: background-color $short-animation-time, box-shadow $long-animation-time, transform $short-animation-time; &::after { content: ''; position: absolute; left: 7px; top: 3px; width: 6px; height: 12px; border: solid $light-color; border-width: 0 2px 2px 0; opacity: 0; transform: rotate(45deg) scale(0.8); transition: opacity $short-animation-time, transform $short-animation-time; } &:checked { background: $text-color; &::after { opacity: 1; transform: rotate(45deg) scale(1); } } &:hover, &:focus-visible { box-shadow: $shadow; } &:active { transform: scale(0.95); } } span { line-height: 1.3; } } .difficulty { @include medium-text(); display: flex; align-items: center; justify-content: center; gap: var(--small-padding); width: max-content; max-width: 100%; margin: 0 auto var(--medium-padding); .stepper { display: flex; align-items: center; gap: var(--small-padding); .value { min-width: 1.5em; text-align: center; font-variant-numeric: tabular-nums; } button.step { all: unset; // strip native + global button styles @include square(22px); @include center-child(); flex: 0 0 auto; box-sizing: border-box; border-radius: 4px; background: $light-color; box-shadow: $shadow-border; cursor: pointer; // all:unset drops the font to serif and the global button's hover // underline (button::after) survives the reset — re-assert both. font: bold 18px/1 $normal-font; color: $text-color; user-select: none; transition: box-shadow $long-animation-time, transform $short-animation-time, opacity $short-animation-time; &::after { content: none; } @media (max-width: $mobile-width) { @include square(26px); } &:hover, &:focus-visible { box-shadow: $shadow; } &:active { transform: scale(0.95); } &:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: $shadow-border; } } } } .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%); } } @media (max-width: $mobile-width) { lt-select-add, .done-checkbox { max-width: 100%; width: 100%; } .bottom { min-height: 42px; button { width: max-content; max-width: 100%; min-height: 42px; } } } } `, }) export class BlockEditComponent implements AfterViewInit { readonly viewTitle = input(''); readonly blocks = input.required(); readonly activeBlockId = input(null); readonly tags = input([]); readonly baseColor = input.required(); /** Default for `is_done` on the create card. */ readonly defaultDone = input(true); readonly save = output(); readonly delete = output(); readonly close = output(); private readonly container = viewChild>('container'); // Per-block edited values, keyed by block ID. readonly editedValues = signal>(new Map()); // Pending new block being authored on the create card. readonly newValue = signal({ tag: '', description: '', is_done: true, difficulty: 1, }); // 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(); for (const b of bs) { m.set(b.id, { tag: b.tag, description: b.description, is_done: b.is_done, difficulty: b.difficulty ?? 1, }); } 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, difficulty: 1, } ); } colorOfTagForBlock(id: string): string { const v = this.editedFor(id); return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent'; } tagPlaceholder(fallback: string): string { return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback; } colorOfNewTag = computed(() => { const t = this.newValue().tag; return t ? getColorOfTag(t, this.baseColor()) : 'transparent'; }); formatDate(ts: number, compact = false): string { const d = new Date(ts * 1000); if (compact) { return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } // e.g. "May 28, 2026, 14:32" return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } // ── 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); } updateDifficulty(id: string, delta: number): void { const next = Math.max(1, this.editedFor(id).difficulty + delta); this.patchEdited(id, { difficulty: next }); this.flushExisting(id); } private patchEdited(id: string, patch: Partial): 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, difficulty: v.difficulty, }); } 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 })); } updateNewDifficulty(delta: number): void { this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) })); } 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, difficulty: v.difficulty, }); 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); } } }