Compare commits
No commits in common. "d1732128e28df2e270f8de3092e2cc3af48e3654" and "e00300de6c7fe5e2ee69d63ec86f46f37135d7c1" have entirely different histories.
d1732128e2
...
e00300de6c
13 changed files with 20 additions and 214 deletions
15
Dockerfile
15
Dockerfile
|
|
@ -7,24 +7,11 @@ WORKDIR /build
|
||||||
# this. The trailing slash matters — it becomes <base href> and the service
|
# this. The trailing slash matters — it becomes <base href> and the service
|
||||||
# worker (ngsw.json) URL prefix.
|
# worker (ngsw.json) URL prefix.
|
||||||
ARG BASE_HREF=/
|
ARG BASE_HREF=/
|
||||||
# Toggle the built-in PWA service worker. Defaults to "enabled" so prod images
|
|
||||||
# ship the full offline-capable PWA unchanged. The dev / e2e compose overrides
|
|
||||||
# this to "disabled" (see docker-compose.dev.yml): it swaps Angular's real
|
|
||||||
# ngsw-worker.js for a self-unregistering safety worker and drops ngsw.json, so
|
|
||||||
# a `--build` rebuild is never shadowed by a stale, client-cached app shell on
|
|
||||||
# the fixed localhost origin. (Docker layer caching already busts the SPA build
|
|
||||||
# on FE source changes; this handles the browser-side service-worker cache.)
|
|
||||||
ARG SERVICE_WORKER=enabled
|
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
RUN npm run build -- --base-href="$BASE_HREF"
|
||||||
# Angular's application builder outputs to dist/frontend/browser/
|
# Angular's application builder outputs to dist/frontend/browser/
|
||||||
RUN npm run build -- --base-href="$BASE_HREF" \
|
|
||||||
&& if [ "$SERVICE_WORKER" = "disabled" ]; then \
|
|
||||||
echo "Neutralising service worker for dev/e2e image"; \
|
|
||||||
rm -f dist/frontend/browser/ngsw.json; \
|
|
||||||
cp safety-worker.js dist/frontend/browser/ngsw-worker.js; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Stage 2: runtime
|
# Stage 2: runtime
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,7 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
life-towers:
|
life-towers:
|
||||||
build:
|
build: .
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
# Strip the PWA service worker from this image so `up --build` always
|
|
||||||
# serves the freshly-built FE assets instead of a stale, SW-cached app
|
|
||||||
# shell. See the SERVICE_WORKER arg in the Dockerfile.
|
|
||||||
SERVICE_WORKER: disabled
|
|
||||||
image: life-towers:dev
|
image: life-towers:dev
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
|
||||||
|
|
@ -180,41 +180,4 @@ test.describe('Life Towers smoke test', () => {
|
||||||
});
|
});
|
||||||
await expectTaskListOpen(page, 'Clean up alerts');
|
await expectTaskListOpen(page, 'Clean up alerts');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression: opening the block-edit carousel must position the target card
|
|
||||||
// INSTANTLY, not animate a scroll across the whole strip. The carousel sets
|
|
||||||
// `scroll-behavior: smooth`, so scrollTo({behavior:'auto'}) would animate —
|
|
||||||
// very visible on mobile where the strip can be thousands of px wide.
|
|
||||||
test('block-edit carousel opens centered without animating the scroll', async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
|
||||||
await page.getByRole('button', { name: 'Load sample towers' }).click();
|
|
||||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
|
||||||
// Let the falling animation settle.
|
|
||||||
await page.waitForTimeout(1800);
|
|
||||||
|
|
||||||
// Open the carousel on a done block deep in the strip (the last square).
|
|
||||||
const squares = page.locator('lt-block');
|
|
||||||
const squareCount = await squares.count();
|
|
||||||
expect(squareCount).toBeGreaterThan(0); // sample data must have produced done blocks
|
|
||||||
await squares.nth(squareCount - 1).click();
|
|
||||||
await page.waitForSelector('lt-block-edit .carousel');
|
|
||||||
|
|
||||||
// Sample scrollLeft immediately and a frame later: an animated scroll would
|
|
||||||
// still be moving; an instant jump is already at its final value.
|
|
||||||
const carousel = page.locator('lt-block-edit .carousel');
|
|
||||||
const first = await carousel.evaluate((c: HTMLElement) => c.scrollLeft);
|
|
||||||
await page.waitForTimeout(60);
|
|
||||||
const second = await carousel.evaluate((c: HTMLElement) => c.scrollLeft);
|
|
||||||
expect(Math.abs(second - first)).toBeLessThan(2); // not mid-animation
|
|
||||||
|
|
||||||
// The active card is centered: left/right viewport gaps match within a few px.
|
|
||||||
const gaps = await page.locator('lt-block-edit .card.active').evaluate((el: HTMLElement) => {
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { left: r.left, right: window.innerWidth - r.right };
|
|
||||||
});
|
|
||||||
expect(Math.abs(gaps.left - gaps.right)).toBeLessThan(8);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
/*
|
|
||||||
* Safety service worker — shipped ONLY in the dev / e2e Docker image
|
|
||||||
* (Dockerfile build arg SERVICE_WORKER=disabled) in place of Angular's real
|
|
||||||
* ngsw-worker.js.
|
|
||||||
*
|
|
||||||
* Why it exists: the dev compose builds a production SPA bundle, so the PWA
|
|
||||||
* service worker is enabled (`enabled: !isDevMode()` in app.config.ts). On the
|
|
||||||
* fixed localhost:8000 origin it would keep serving a stale, client-cached app
|
|
||||||
* shell across `docker compose -f docker-compose.dev.yml up --build` rebuilds,
|
|
||||||
* shadowing freshly-built FE assets.
|
|
||||||
*
|
|
||||||
* This worker's only job is to disappear: it claims any open clients, deletes
|
|
||||||
* every Cache Storage entry, then unregisters itself. Any previously-installed
|
|
||||||
* real service worker is evicted on its next update check (the browser fetches
|
|
||||||
* this changed ngsw-worker.js and replaces the old one with this), and no
|
|
||||||
* service worker is ever left controlling the page — so assets are always
|
|
||||||
* fetched from network, where index.html is `no-cache` and bundles are
|
|
||||||
* content-hashed.
|
|
||||||
*
|
|
||||||
* It deliberately does NOT call client.navigate(): the bundle re-registers on
|
|
||||||
* every load, so a forced reload would create a loop. The only cost is a
|
|
||||||
* harmless register -> unregister cycle per load.
|
|
||||||
*/
|
|
||||||
self.addEventListener('install', () => self.skipWaiting());
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await self.clients.claim();
|
|
||||||
const keys = await caches.keys();
|
|
||||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
|
||||||
} finally {
|
|
||||||
await self.registration.unregister();
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -68,8 +68,8 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
|
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
|
||||||
(click)="onCardClick(i + 1)"
|
(click)="onCardClick(i + 1)"
|
||||||
(keydown.enter)="onCardKey($event, i + 1)"
|
(keydown.enter)="onCardClick(i + 1)"
|
||||||
(keydown.space)="onCardKey($event, i + 1)"
|
(keydown.space)="$event.preventDefault(); onCardClick(i + 1)"
|
||||||
>
|
>
|
||||||
<div class="mask"></div>
|
<div class="mask"></div>
|
||||||
|
|
||||||
|
|
@ -151,8 +151,8 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Focus create card"
|
aria-label="Focus create card"
|
||||||
(click)="onCardClick(blocks().length + 1)"
|
(click)="onCardClick(blocks().length + 1)"
|
||||||
(keydown.enter)="onCardKey($event, blocks().length + 1)"
|
(keydown.enter)="onCardClick(blocks().length + 1)"
|
||||||
(keydown.space)="onCardKey($event, blocks().length + 1)"
|
(keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)"
|
||||||
>
|
>
|
||||||
<div class="mask"></div>
|
<div class="mask"></div>
|
||||||
|
|
||||||
|
|
@ -242,12 +242,6 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
// Cover the *visible* viewport. Sizing via top:0/bottom:0 uses the layout
|
|
||||||
// viewport, which on mobile extends behind the browser's bottom toolbar
|
|
||||||
// (and the soft keyboard) — so the vertically-centered card gets its
|
|
||||||
// bottom cut off. 100dvh tracks the area that's actually on screen;
|
|
||||||
// browsers without dvh fall back to the top/bottom inset above.
|
|
||||||
height: 100dvh;
|
|
||||||
z-index: 10001; // above modal backdrop (10000)
|
z-index: 10001; // above modal backdrop (10000)
|
||||||
|
|
||||||
@media (max-height: $min-height) {
|
@media (max-height: $min-height) {
|
||||||
|
|
@ -285,12 +279,6 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
padding: var(--title-clearance) var(--medium-padding) var(--medium-padding);
|
padding: var(--title-clearance) var(--medium-padding) var(--medium-padding);
|
||||||
// Keep the card centered when it fits, but never clip it: 'safe center'
|
|
||||||
// falls back to top-alignment when the card is taller than the visible
|
|
||||||
// viewport, and overflow-y lets the user scroll down to the bottom
|
|
||||||
// (delete/create button) — e.g. when the soft keyboard shrinks the view.
|
|
||||||
align-items: safe center;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: $min-height) {
|
@media (max-height: $min-height) {
|
||||||
|
|
@ -800,19 +788,6 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Activate a card via Space/Enter only when the card itself is focused. The
|
|
||||||
* card is a role="button" that wraps the description textarea and the tag
|
|
||||||
* input; without this guard the keydown bubbles up from those fields and the
|
|
||||||
* space handler's preventDefault() swallows the space, making it impossible
|
|
||||||
* to type spaces while editing a block.
|
|
||||||
*/
|
|
||||||
onCardKey(event: Event, idx: number): void {
|
|
||||||
if (event.target !== event.currentTarget) return;
|
|
||||||
event.preventDefault();
|
|
||||||
this.onCardClick(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close the carousel when the user clicks anywhere that isn't a real card. */
|
/** Close the carousel when the user clicks anywhere that isn't a real card. */
|
||||||
onBackdropClick(event: MouseEvent): void {
|
onBackdropClick(event: MouseEvent): void {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
|
|
@ -840,12 +815,7 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const left =
|
const left =
|
||||||
card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||||
// 'instant' (not 'auto') is required: the carousel sets `scroll-behavior:
|
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' });
|
||||||
// smooth`, and 'auto' defers to that — so the initial open would *animate*
|
|
||||||
// a scroll across the whole strip to reach the target card (very visible on
|
|
||||||
// mobile, where the carousel can be thousands of px wide). Tap-to-navigate
|
|
||||||
// still passes smooth=true for the nice slide between cards.
|
|
||||||
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' });
|
|
||||||
this.activeIdx.set(idx);
|
this.activeIdx.set(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,21 +97,11 @@ export class ModalComponent implements AfterViewInit, OnDestroy {
|
||||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
||||||
private previousFocus: HTMLElement | null = null;
|
private previousFocus: HTMLElement | null = null;
|
||||||
private readonly modalState = inject(ModalStateService);
|
private readonly modalState = inject(ModalStateService);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
this.previousFocus = document.activeElement as HTMLElement;
|
this.previousFocus = document.activeElement as HTMLElement;
|
||||||
// Track open state so towers can be locked while any modal is mounted.
|
// Track open state so towers can be locked while any modal is mounted.
|
||||||
this.modalState.open();
|
this.modalState.open();
|
||||||
// Hoist the modal to <body> so its position:fixed references the viewport.
|
|
||||||
// Tower-level modals (block-edit, tower-settings) are rendered inside the
|
|
||||||
// .towers horizontal scroll container; on iOS Safari a position:fixed
|
|
||||||
// descendant of a scrolling/overflow ancestor is clipped to that ancestor's
|
|
||||||
// box instead of the viewport — cutting off the card's bottom and confining
|
|
||||||
// the backdrop to the towers band (title + sliders show through). Moving the
|
|
||||||
// host out of that ancestor restores true viewport-fixed behaviour. Angular
|
|
||||||
// removes the node via its *current* parent on destroy, so this is safe.
|
|
||||||
document.body.appendChild(this.host.nativeElement);
|
|
||||||
// Defer one tick so the opacity transition runs (0 → 1).
|
// Defer one tick so the opacity transition runs (0 → 1).
|
||||||
setTimeout(() => this.active.set(true), 0);
|
setTimeout(() => this.active.set(true), 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
@for (tower of page().towers; track tower.id) {
|
@for (tower of page().towers; track tower.id) {
|
||||||
<lt-tower
|
<lt-tower
|
||||||
cdkDrag
|
cdkDrag
|
||||||
[attr.data-tower-id]="tower.id"
|
|
||||||
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
|
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
|
||||||
[tower]="tower"
|
[tower]="tower"
|
||||||
[dateRange]="dateRange()"
|
[dateRange]="dateRange()"
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,8 @@
|
||||||
|
|
||||||
.add-tower-wrapper {
|
.add-tower-wrapper {
|
||||||
@include center-child();
|
@include center-child();
|
||||||
|
|
||||||
img.add-tower {
|
img.add-tower {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
@ -77,8 +75,7 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: visible;
|
||||||
touch-action: pan-x;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
scroll-padding-inline: var(--mobile-tower-side-padding);
|
scroll-padding-inline: var(--mobile-tower-side-padding);
|
||||||
|
|
@ -105,28 +102,19 @@
|
||||||
.double-slider-container {
|
.double-slider-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: opacity $long-animation-time;
|
transition: opacity $long-animation-time;
|
||||||
|
@media (max-height: $min-height) { display: none; }
|
||||||
@media (max-height: $min-height) {
|
&.transparent { opacity: 0; pointer-events: none; }
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.transparent {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-delete {
|
.confirm-delete {
|
||||||
@include card();
|
@include card();
|
||||||
width: 66vw;
|
width: 66vw;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
width: 88vw;
|
width: 88vw;
|
||||||
max-width: 88vw;
|
max-width: 88vw;
|
||||||
padding: var(--medium-padding);
|
padding: var(--medium-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: var(--large-padding);
|
padding: var(--large-padding);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -136,7 +124,6 @@
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@include center-child();
|
@include center-child();
|
||||||
|
|
||||||
.exit {
|
.exit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--large-padding);
|
right: var(--large-padding);
|
||||||
|
|
@ -145,9 +132,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
strong {
|
strong { font-weight: bold; }
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buttons {
|
.confirm-buttons {
|
||||||
|
|
@ -162,19 +147,13 @@
|
||||||
button.danger {
|
button.danger {
|
||||||
color: #b53f3f;
|
color: #b53f3f;
|
||||||
border-bottom-color: #b53f3f55;
|
border-bottom-color: #b53f3f55;
|
||||||
|
&:after { background-color: #b53f3f; }
|
||||||
&:after {
|
|
||||||
background-color: #b53f3f;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--small-padding);
|
gap: var(--small-padding);
|
||||||
|
button { width: 100%; }
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ import {
|
||||||
HostListener,
|
HostListener,
|
||||||
effect,
|
effect,
|
||||||
untracked,
|
untracked,
|
||||||
ElementRef,
|
|
||||||
Injector,
|
|
||||||
afterNextRender,
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Page } from '../../models';
|
import { Page } from '../../models';
|
||||||
import { StoreService } from '../../services/store.service';
|
import { StoreService } from '../../services/store.service';
|
||||||
|
|
@ -72,8 +69,6 @@ export class PageComponent {
|
||||||
|
|
||||||
protected readonly store = inject(StoreService);
|
protected readonly store = inject(StoreService);
|
||||||
private readonly modalState = inject(ModalStateService);
|
private readonly modalState = inject(ModalStateService);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
private readonly injector = inject(Injector);
|
|
||||||
/** True while any lt-modal is mounted — used to lock tower drag. */
|
/** True while any lt-modal is mounted — used to lock tower drag. */
|
||||||
readonly modalOpen = this.modalState.anyOpen;
|
readonly modalOpen = this.modalState.anyOpen;
|
||||||
readonly mobileDragDisabled = signal(this.isMobileViewport());
|
readonly mobileDragDisabled = signal(this.isMobileViewport());
|
||||||
|
|
@ -151,32 +146,7 @@ export class PageComponent {
|
||||||
|
|
||||||
onAddTower(result: TowerSettingsResult): void {
|
onAddTower(result: TowerSettingsResult): void {
|
||||||
this.showAddTower.set(false);
|
this.showAddTower.set(false);
|
||||||
const towerId = this.store.addTower(this.page().id, result.name, result.base_color);
|
this.store.addTower(this.page().id, result.name, result.base_color);
|
||||||
this.centerTowerOnMobile(towerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On mobile the tower row is a horizontal scroll-snap container. Adding a
|
|
||||||
* tower appends it next to the (far-right) "+" button, so without this the
|
|
||||||
* view stays scrolled on the "+" button rather than the new tower. Center
|
|
||||||
* the freshly-created tower once it's painted. No-op on desktop, where the
|
|
||||||
* row is centered and doesn't scroll.
|
|
||||||
*/
|
|
||||||
private centerTowerOnMobile(towerId: string): void {
|
|
||||||
if (!this.isMobileViewport()) return;
|
|
||||||
afterNextRender(
|
|
||||||
() => {
|
|
||||||
const container = this.host.nativeElement.querySelector<HTMLElement>('.towers');
|
|
||||||
const tower = container?.querySelector<HTMLElement>(`[data-tower-id="${towerId}"]`);
|
|
||||||
if (!container || !tower) return;
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const towerRect = tower.getBoundingClientRect();
|
|
||||||
const delta =
|
|
||||||
towerRect.left + towerRect.width / 2 - (containerRect.left + containerRect.width / 2);
|
|
||||||
container.scrollTo({ left: container.scrollLeft + delta, behavior: 'smooth' });
|
|
||||||
},
|
|
||||||
{ injector: this.injector },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdateTower(towerId: string, result: TowerSettingsResult): void {
|
onUpdateTower(towerId: string, result: TowerSettingsResult): void {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
width: 80vw;
|
width: 80vw;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
|
|
@ -28,9 +27,8 @@
|
||||||
// Generous breathing room between the page selector dropdown and the
|
// Generous breathing room between the page selector dropdown and the
|
||||||
// towers — the dropdown can open downward without crowding the towers.
|
// towers — the dropdown can open downward without crowding the towers.
|
||||||
padding-top: var(--large-padding);
|
padding-top: var(--large-padding);
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
padding-top: var(--small-padding);
|
padding-top: var(--medium-padding);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +40,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
|
margin-top: var(--medium-padding);
|
||||||
font-size: var(--medium-font-size);
|
font-size: var(--medium-font-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,13 +49,11 @@
|
||||||
@include card();
|
@include card();
|
||||||
width: 66vw;
|
width: 66vw;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
width: 88vw;
|
width: 88vw;
|
||||||
max-width: 88vw;
|
max-width: 88vw;
|
||||||
padding: var(--medium-padding);
|
padding: var(--medium-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: var(--large-padding);
|
padding: var(--large-padding);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ export interface DoubleSliderRange<T> {
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
margin-top: calc(#{$slider-size} / 2);
|
margin-top: calc(#{$slider-size} / 2);
|
||||||
height: 54px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
label { display: none; }
|
label { display: none; }
|
||||||
|
|
@ -154,8 +153,8 @@ export interface DoubleSliderRange<T> {
|
||||||
|
|
||||||
@media (max-width: $mobile-width) {
|
@media (max-width: $mobile-width) {
|
||||||
font-size: var(--small-font-size);
|
font-size: var(--small-font-size);
|
||||||
margin-top: calc(#{$slider-size} - 12px);
|
margin-top: $slider-size;
|
||||||
span { margin-top: 8px; }
|
span { margin-top: 10px; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ export class StoreService implements OnDestroy {
|
||||||
if (changed) this.scheduleSave();
|
if (changed) this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
addTower(pageId: string, name: string, base_color: HslColor): string {
|
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||||
const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] };
|
const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] };
|
||||||
this._pages.update((pages) =>
|
this._pages.update((pages) =>
|
||||||
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
||||||
|
|
@ -319,7 +319,6 @@ export class StoreService implements OnDestroy {
|
||||||
this.analytics.trackStart();
|
this.analytics.trackStart();
|
||||||
this.analytics.trackTowerCreated();
|
this.analytics.trackTowerCreated();
|
||||||
this.scheduleSave();
|
this.scheduleSave();
|
||||||
return tower.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTower(pageId: string, towerId: string, patch: Partial<Omit<Tower, 'id' | 'blocks'>>): void {
|
updateTower(pageId: string, towerId: string, patch: Partial<Omit<Tower, 'id' | 'blocks'>>): void {
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,7 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Life Towers</title>
|
<title>Life Towers</title>
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#5d576b" />
|
<meta name="theme-color" content="#5d576b" />
|
||||||
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue