frontend(page): update page layout, slider and page selector
This commit is contained in:
parent
108cfb9a19
commit
8390ece334
6 changed files with 257 additions and 46 deletions
|
|
@ -7,10 +7,11 @@
|
|||
@for (tower of page().towers; track tower.id) {
|
||||
<lt-tower
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="modalOpen()"
|
||||
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
|
||||
[tower]="tower"
|
||||
[dateRange]="dateRange()"
|
||||
[keepTasksOpen]="page().keep_tasks_open"
|
||||
[animateInitialStack]="animateInitialStack()"
|
||||
(cdkDragStarted)="onTowerDragStart(tower.id)"
|
||||
(updateTower)="onUpdateTower(tower.id, $event)"
|
||||
(deleteTowerRequest)="onDeleteTower(tower.id)"
|
||||
|
|
@ -21,7 +22,16 @@
|
|||
}
|
||||
@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)" />
|
||||
<img
|
||||
class="add-tower"
|
||||
src="assets/plus-sign.svg"
|
||||
alt="Add tower"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="showAddTower.set(true)"
|
||||
(keydown.enter)="showAddTower.set(true)"
|
||||
(keydown.space)="$event.preventDefault(); showAddTower.set(true)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -48,7 +58,7 @@
|
|||
|
||||
@if (showAddTower()) {
|
||||
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" />
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +66,7 @@
|
|||
<lt-modal (close)="cancelTowerDelete()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
|
||||
<button class="exit" type="button" (click)="cancelTowerDelete()" aria-label="Cancel"></button>
|
||||
<h2>Delete tower</h2>
|
||||
</div>
|
||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative; // anchor for absolute-positioned .trash
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
|
@ -18,14 +19,17 @@
|
|||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
flex: 1 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
transition: box-shadow $short-animation-time;
|
||||
|
||||
max-width: 800px;
|
||||
max-width: 100%;
|
||||
gap: var(--medium-padding);
|
||||
|
||||
&.cdk-drop-list-dragging {
|
||||
*:not(.cdk-drag-placeholder) {
|
||||
|
|
@ -52,28 +56,45 @@
|
|||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
box-sizing: content-box;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:not(:nth-last-child(1)) {
|
||||
margin-right: var(--medium-padding);
|
||||
@media (max-width: $mobile-width) {
|
||||
margin-right: var(--small-padding);
|
||||
}
|
||||
}
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
|
||||
@for $i from 1 to 12 {
|
||||
& > *:first-child:nth-last-child(#{$i}),
|
||||
& > *:first-child:nth-last-child(#{$i}) ~ * {
|
||||
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
|
||||
// Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
|
||||
@media (max-width: $mobile-width) {
|
||||
--mobile-tower-width: calc(66vw - var(--small-padding));
|
||||
--mobile-tower-side-padding: max(
|
||||
var(--medium-padding),
|
||||
calc((100% - var(--mobile-tower-width)) / 2)
|
||||
);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
|
||||
}
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: var(--mobile-tower-side-padding);
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
padding: 0 var(--mobile-tower-side-padding);
|
||||
max-width: 100%;
|
||||
gap: var(--medium-padding);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: var(--mobile-tower-width) !important;
|
||||
max-width: var(--mobile-tower-width) !important;
|
||||
min-width: var(--mobile-tower-width) !important;
|
||||
scroll-snap-align: center;
|
||||
flex: 0 0 var(--mobile-tower-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,7 +110,11 @@
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
|
|
@ -101,7 +126,7 @@
|
|||
@include center-child();
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
right: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
|
@ -115,11 +140,21 @@
|
|||
justify-content: center;
|
||||
gap: var(--large-padding);
|
||||
|
||||
button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #b53f3f;
|
||||
border-bottom-color: #b53f3f55;
|
||||
&:after { background-color: #b53f3f; }
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex-direction: column;
|
||||
gap: var(--small-padding);
|
||||
button { width: 100%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
HostListener,
|
||||
effect,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
|
|
@ -15,7 +19,6 @@ 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';
|
||||
|
||||
|
|
@ -38,6 +41,7 @@ interface BlockPatch {
|
|||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
/** Minimum blocks before the date-range slider becomes visible. */
|
||||
|
|
@ -60,12 +64,14 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
|
|||
})
|
||||
export class PageComponent {
|
||||
readonly page = input.required<Page>();
|
||||
readonly animateInitialStack = input<boolean>(false);
|
||||
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 mobileDragDisabled = signal(this.isMobileViewport());
|
||||
|
||||
readonly showAddTower = signal(false);
|
||||
readonly isDragging = signal(false);
|
||||
|
|
@ -112,10 +118,30 @@ export class PageComponent {
|
|||
/** Selected date range — `null` = show everything. */
|
||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.showSlider()) {
|
||||
untracked(() => this.dateRange.set(null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
||||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
this.mobileDragDisabled.set(this.isMobileViewport());
|
||||
}
|
||||
|
||||
private isMobileViewport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(max-width: 520px), (pointer: coarse)').matches
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tower mutations ────────────────────────────────────────────────────────
|
||||
|
||||
onAddTower(result: TowerSettingsResult): void {
|
||||
|
|
@ -131,7 +157,7 @@ export class PageComponent {
|
|||
}
|
||||
|
||||
onDeleteTower(towerId: string): void {
|
||||
this.store.deleteTower(this.page().id, towerId);
|
||||
this.confirmDeleteTowerId.set(towerId);
|
||||
}
|
||||
|
||||
// ── Block mutations ────────────────────────────────────────────────────────
|
||||
|
|
@ -143,6 +169,7 @@ export class PageComponent {
|
|||
result.tag,
|
||||
result.description,
|
||||
result.is_done,
|
||||
result.difficulty,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -187,13 +214,16 @@ export class PageComponent {
|
|||
|
||||
onTrashEnter(): void {
|
||||
this.nearTrashcan = true;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.add('trash-highlight');
|
||||
this.dragPreview()?.classList.add('trash-highlight');
|
||||
}
|
||||
|
||||
onTrashLeave(): void {
|
||||
this.nearTrashcan = false;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.remove('trash-highlight');
|
||||
this.dragPreview()?.classList.remove('trash-highlight');
|
||||
}
|
||||
|
||||
/** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */
|
||||
private dragPreview(): Element | null {
|
||||
return document.querySelector('.cdk-drag-preview');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
|
||||
<div class="page-container">
|
||||
@if (selectedPage(); as page) {
|
||||
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" />
|
||||
<lt-page
|
||||
[page]="page"
|
||||
[animateInitialStack]="page.id === animateInitialStackPageId()"
|
||||
(dragHappening)="dragHappening.set($event)"
|
||||
/>
|
||||
} @else {
|
||||
<p>Add a new page to get started!</p>
|
||||
}
|
||||
|
|
@ -27,14 +31,37 @@
|
|||
[page]="selectedPage()"
|
||||
(close)="showSettings.set(false)"
|
||||
(updatePage)="onUpdatePage($event)"
|
||||
(deletePage)="onRemovePage()"
|
||||
(deletePage)="onRequestRemovePage()"
|
||||
(switchAccount)="onSwitchAccount($event)"
|
||||
/>
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
@if (confirmDeletePageId()) {
|
||||
<lt-modal (close)="cancelRemovePage()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<button class="exit" type="button" (click)="cancelRemovePage()" aria-label="Cancel"></button>
|
||||
<h2>Delete page</h2>
|
||||
</div>
|
||||
<p>
|
||||
Delete <strong>{{ confirmDeletePageName() || 'this page' }}</strong> and all of its towers and blocks?
|
||||
This can't be undone.
|
||||
</p>
|
||||
<div class="confirm-buttons">
|
||||
<button type="button" (click)="cancelRemovePage()">Cancel</button>
|
||||
<button type="button" class="danger" (click)="confirmRemovePage()">Delete page</button>
|
||||
</div>
|
||||
</div>
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
@if (showWelcome()) {
|
||||
<lt-modal (close)="showWelcome.set(false)">
|
||||
<lt-modal
|
||||
labelledBy="welcome-title"
|
||||
describedBy="welcome-description"
|
||||
(close)="showWelcome.set(false)"
|
||||
>
|
||||
<lt-welcome
|
||||
(close)="showWelcome.set(false)"
|
||||
(startFresh)="showWelcome.set(false)"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -11,14 +12,24 @@
|
|||
|
||||
.select-add-container {
|
||||
width: 250px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 80vw;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex: 1 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
// Generous breathing room between the page selector dropdown and the
|
||||
// towers — the dropdown can open downward without crowding the towers.
|
||||
padding-top: var(--large-padding);
|
||||
@media (max-width: $mobile-width) {
|
||||
padding-top: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
@ -27,5 +38,65 @@
|
|||
&.transparent {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
margin-top: var(--medium-padding);
|
||||
font-size: var(--medium-font-size);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
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;
|
||||
right: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--large-padding);
|
||||
|
||||
button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #b53f3f;
|
||||
border-bottom-color: #b53f3f55;
|
||||
|
||||
&:after {
|
||||
background-color: #b53f3f;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex-direction: column;
|
||||
gap: var(--small-padding);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
signal,
|
||||
computed,
|
||||
effect,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
import { PageComponent } from '../page/page.component';
|
||||
|
|
@ -22,7 +23,7 @@ import { Page } from '../../models';
|
|||
templateUrl: './pages.component.html',
|
||||
styleUrl: './pages.component.scss',
|
||||
})
|
||||
export class PagesComponent {
|
||||
export class PagesComponent implements OnDestroy {
|
||||
protected readonly store = inject(StoreService);
|
||||
|
||||
/** ID of currently selected page within store.pages(). */
|
||||
|
|
@ -31,22 +32,43 @@ export class PagesComponent {
|
|||
readonly showSettings = signal(false);
|
||||
readonly dragHappening = signal(false);
|
||||
readonly showWelcome = signal(false);
|
||||
readonly confirmDeletePageId = signal<string | null>(null);
|
||||
readonly animateInitialStackPageId = signal<string | null>(null);
|
||||
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.store.loading() && this.store.pages().length === 0) {
|
||||
const pages = this.store.pages();
|
||||
if (!this.store.loading() && pages.length === 0) {
|
||||
this.showWelcome.set(true);
|
||||
} else if (this.store.pages().length > 0) {
|
||||
} else if (pages.length > 0) {
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onLoadExample(): void {
|
||||
this.store.loadExample();
|
||||
const pageId = this.store.loadExample();
|
||||
this.selectedPageId.set(pageId);
|
||||
this.animateInitialStackPageId.set(pageId);
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
this.exampleAnimationTimer = setTimeout(() => {
|
||||
if (this.animateInitialStackPageId() === pageId) {
|
||||
this.animateInitialStackPageId.set(null);
|
||||
}
|
||||
this.exampleAnimationTimer = null;
|
||||
}, 2500);
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
}
|
||||
|
||||
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
|
||||
|
||||
readonly selectedPage = computed<Page | null>(() => {
|
||||
|
|
@ -61,12 +83,17 @@ export class PagesComponent {
|
|||
return pages[0] ?? null;
|
||||
});
|
||||
|
||||
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
|
||||
readonly confirmDeletePageName = computed(() => {
|
||||
const id = this.confirmDeletePageId();
|
||||
if (!id) return '';
|
||||
return this.store.pages().find((p) => p.id === id)?.name ?? '';
|
||||
});
|
||||
|
||||
readonly selectedPageIndex = computed(() => {
|
||||
const pages = this.store.pages();
|
||||
const page = this.selectedPage();
|
||||
if (!page) return -1;
|
||||
return this.store.pages().findIndex((p) => p.id === page.id);
|
||||
return pages.findIndex((p) => p.id === page.id);
|
||||
});
|
||||
|
||||
onSelectPage(index: number): void {
|
||||
|
|
@ -94,12 +121,23 @@ export class PagesComponent {
|
|||
}
|
||||
}
|
||||
|
||||
onRemovePage(): void {
|
||||
onRequestRemovePage(): void {
|
||||
const page = this.selectedPage();
|
||||
if (!page) return;
|
||||
this.store.deletePage(page.id);
|
||||
this.confirmDeletePageId.set(page.id);
|
||||
}
|
||||
|
||||
confirmRemovePage(): void {
|
||||
const pageId = this.confirmDeletePageId();
|
||||
if (!pageId) return;
|
||||
this.store.deletePage(pageId);
|
||||
this.selectedPageId.set(null);
|
||||
this.showSettings.set(false);
|
||||
this.confirmDeletePageId.set(null);
|
||||
}
|
||||
|
||||
cancelRemovePage(): void {
|
||||
this.confirmDeletePageId.set(null);
|
||||
}
|
||||
|
||||
onSwitchAccount(token: string): void {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue