snapshot
This commit is contained in:
parent
3ad2766f82
commit
f74ee43cb4
196 changed files with 18949 additions and 32173 deletions
|
|
@ -1,16 +0,0 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { PagesComponent } from './components/pages/pages.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: PagesComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<main (click)="cancelService.cancelAll()">
|
||||
<app-modal></app-modal>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, DoCheck } from '@angular/core';
|
||||
import { CancelService } from './services/cancel.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AppComponent implements DoCheck {
|
||||
title = 'life';
|
||||
|
||||
constructor(public cancelService: CancelService) {
|
||||
window.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
this.cancelService.cancelAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngDoCheck() {
|
||||
// console.log('app change detection');
|
||||
}
|
||||
}
|
||||
22
frontend/src/app/app.config.ts
Normal file
22
frontend/src/app/app.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
isDevMode,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter([]),
|
||||
provideHttpClient(withFetch()),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
}),
|
||||
],
|
||||
};
|
||||
343
frontend/src/app/app.html
Normal file
343
frontend/src/app/app.html
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
]; track item.title) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { PageComponent } from './components/pages/page/page.component';
|
||||
import { TowerComponent } from './components/pages/page/tower/tower.component';
|
||||
import { DoubleSliderComponent } from './components/shared/double-slider/double-slider.component';
|
||||
import { PagesComponent } from './components/pages/pages.component';
|
||||
import { SelectAddComponent } from './components/shared/select-add/select-add.component';
|
||||
import { ModalComponent } from './components/modal/modal.component';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BlockComponent } from './components/pages/page/tower/block/block.component';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { SettingsComponent } from './components/modal/modals/settings/settings.component';
|
||||
import { RemoveTowerComponent } from './components/modal/modals/remove-tower/remove-tower.component';
|
||||
import { RemovePageComponent } from './components/modal/modals/remove-page/remove-page.component';
|
||||
import { GetStartedComponent } from './components/modal/modals/get-started/get-started.component';
|
||||
import { ToggleComponent } from './components/shared/toggle/toggle.component';
|
||||
import { TasksComponent } from './components/pages/page/tower/tasks/tasks.component';
|
||||
import { ColorPipe } from './pipes/color.pipe';
|
||||
import { BlocksComponent } from './components/modal/modals/blocks/blocks.component';
|
||||
import { FormatDatePipe } from './pipes/format-date.pipe';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
PageComponent,
|
||||
BlockComponent,
|
||||
TowerComponent,
|
||||
DoubleSliderComponent,
|
||||
PagesComponent,
|
||||
SelectAddComponent,
|
||||
ModalComponent,
|
||||
BlockComponent,
|
||||
SettingsComponent,
|
||||
RemoveTowerComponent,
|
||||
RemovePageComponent,
|
||||
GetStartedComponent,
|
||||
ToggleComponent,
|
||||
TasksComponent,
|
||||
ColorPipe,
|
||||
BlocksComponent,
|
||||
FormatDatePipe
|
||||
],
|
||||
imports: [BrowserModule, AppRoutingModule, FormsModule, BrowserAnimationsModule, DragDropModule, HttpClientModule],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule {}
|
||||
18
frontend/src/app/app.ts
Normal file
18
frontend/src/app/app.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
|
||||
import { StoreService } from './services/store.service';
|
||||
import { PagesComponent } from './components/pages/pages.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [PagesComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<lt-pages />`,
|
||||
})
|
||||
export class App implements OnInit {
|
||||
private readonly store = inject(StoreService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.store.init();
|
||||
}
|
||||
}
|
||||
43
frontend/src/app/components/block/block.component.ts
Normal file
43
frontend/src/app/components/block/block.component.ts
Normal 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()));
|
||||
}
|
||||
521
frontend/src/app/components/modal/block-edit.component.ts
Normal file
521
frontend/src/app/components/modal/block-edit.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<p>
|
||||
get-started works!
|
||||
</p>
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
141
frontend/src/app/components/modal/page-settings.component.ts
Normal file
141
frontend/src/app/components/modal/page-settings.component.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
266
frontend/src/app/components/modal/settings.component.ts
Normal file
266
frontend/src/app/components/modal/settings.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
124
frontend/src/app/components/modal/tower-settings.component.ts
Normal file
124
frontend/src/app/components/modal/tower-settings.component.ts
Normal 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 };
|
||||
}
|
||||
69
frontend/src/app/components/page/page.component.html
Normal file
69
frontend/src/app/components/page/page.component.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<section
|
||||
class="towers"
|
||||
cdkDropList
|
||||
cdkDropListOrientation="horizontal"
|
||||
(cdkDropListDropped)="onTowerDropped($event)"
|
||||
>
|
||||
@for (tower of page().towers; track tower.id) {
|
||||
<lt-tower
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="modalOpen()"
|
||||
[tower]="tower"
|
||||
[dateRange]="dateRange()"
|
||||
[keepTasksOpen]="page().keep_tasks_open"
|
||||
(cdkDragStarted)="onTowerDragStart(tower.id)"
|
||||
(updateTower)="onUpdateTower(tower.id, $event)"
|
||||
(deleteTowerRequest)="onDeleteTower(tower.id)"
|
||||
(saveBlock)="onSaveBlock(tower.id, $event)"
|
||||
(addBlock)="onAddBlock(tower.id, $event)"
|
||||
(deleteBlock)="onDeleteBlock(tower.id, $event)"
|
||||
/>
|
||||
}
|
||||
@if (!page().hide_create_tower_button) {
|
||||
<div class="add-tower-wrapper">
|
||||
<img class="add-tower" src="assets/plus-sign.svg" alt="Add tower" (click)="showAddTower.set(true)" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Trash zone is positioned relative to :host, not .towers — matches legacy. -->
|
||||
<img
|
||||
class="trash"
|
||||
src="assets/trash.svg"
|
||||
alt="Delete tower"
|
||||
[class.active]="isDragging()"
|
||||
(pointerenter)="onTrashEnter()"
|
||||
(pointerleave)="onTrashLeave()"
|
||||
/>
|
||||
|
||||
@if (showSlider()) {
|
||||
<div class="double-slider-container" [class.transparent]="isDragging()">
|
||||
<lt-double-slider
|
||||
[values]="blockDates()"
|
||||
[labels]="dateLabels()"
|
||||
(rangeChange)="onSliderRangeChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showAddTower()) {
|
||||
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
|
||||
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" />
|
||||
</lt-modal>
|
||||
}
|
||||
|
||||
@if (confirmDeleteTowerId(); as towerId) {
|
||||
<lt-modal (close)="cancelTowerDelete()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
|
||||
<h2>Delete tower</h2>
|
||||
</div>
|
||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
||||
<div class="confirm-buttons">
|
||||
<button type="button" (click)="cancelTowerDelete()">Cancel</button>
|
||||
<button type="button" class="danger" (click)="confirmTowerDelete()">Delete tower</button>
|
||||
</div>
|
||||
</div>
|
||||
</lt-modal>
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
199
frontend/src/app/components/page/page.component.ts
Normal file
199
frontend/src/app/components/page/page.component.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
import { TowerComponent } from '../tower/tower.component';
|
||||
import { ModalComponent } from '../modal/modal.component';
|
||||
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
|
||||
import {
|
||||
DoubleSliderComponent,
|
||||
DoubleSliderRange,
|
||||
} from '../shared/double-slider/double-slider.component';
|
||||
import { computed } from '@angular/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import { ModalStateService } from '../../services/modal-state.service';
|
||||
|
||||
// ── Relative-time helpers ──────────────────────────────────────────────────
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' });
|
||||
|
||||
function formatRelative(ts: number, nowSec: number): string {
|
||||
const diff = ts - nowSec; // negative = past
|
||||
const absDiff = Math.abs(diff);
|
||||
if (absDiff < 45) return rtf.format(Math.round(diff), 'second');
|
||||
if (absDiff < 60 * 45) return rtf.format(Math.round(diff / 60), 'minute');
|
||||
if (absDiff < 60 * 60 * 22) return rtf.format(Math.round(diff / 3600), 'hour');
|
||||
if (absDiff < 86400 * 26) return rtf.format(Math.round(diff / 86400), 'day');
|
||||
if (absDiff < 86400 * 320) return rtf.format(Math.round(diff / 86400 / 30), 'month');
|
||||
return rtf.format(Math.round(diff / 86400 / 365), 'year');
|
||||
}
|
||||
|
||||
interface BlockPatch {
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
}
|
||||
|
||||
/** Minimum blocks before the date-range slider becomes visible. */
|
||||
const MIN_BLOCKS_FOR_SLIDER = 2;
|
||||
|
||||
@Component({
|
||||
selector: 'lt-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TowerComponent,
|
||||
ModalComponent,
|
||||
TowerSettingsComponent,
|
||||
DoubleSliderComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './page.component.html',
|
||||
styleUrl: './page.component.scss',
|
||||
})
|
||||
export class PageComponent {
|
||||
readonly page = input.required<Page>();
|
||||
readonly dragHappening = output<boolean>();
|
||||
|
||||
protected readonly store = inject(StoreService);
|
||||
private readonly modalState = inject(ModalStateService);
|
||||
/** True while any lt-modal is mounted — used to lock tower drag. */
|
||||
readonly modalOpen = this.modalState.anyOpen;
|
||||
|
||||
readonly showAddTower = signal(false);
|
||||
readonly isDragging = signal(false);
|
||||
/** When set, opens a confirmation modal before the dragged tower is deleted. */
|
||||
readonly confirmDeleteTowerId = signal<string | null>(null);
|
||||
private draggedTowerId: string | null = null;
|
||||
private nearTrashcan = false;
|
||||
|
||||
readonly confirmDeleteTowerName = computed(() => {
|
||||
const id = this.confirmDeleteTowerId();
|
||||
if (!id) return '';
|
||||
return this.page().towers.find((t) => t.id === id)?.name ?? '';
|
||||
});
|
||||
|
||||
// ── Date-range slider state ────────────────────────────────────────────────
|
||||
|
||||
/** Sorted unique `created_at` timestamps (seconds) across all done blocks
|
||||
* in this page. Empty list when no blocks yet. */
|
||||
readonly blockDates = computed<number[]>(() => {
|
||||
const set = new Set<number>();
|
||||
for (const tower of this.page().towers) {
|
||||
for (const b of tower.blocks) if (b.is_done) set.add(b.created_at);
|
||||
}
|
||||
return [...set].sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
/** Date labels formatted for slider display (deduplicated, insertion order). */
|
||||
readonly dateLabels = computed<string[]>(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const t of this.blockDates()) {
|
||||
const lbl = formatRelative(t, now);
|
||||
if (!seen.has(lbl)) {
|
||||
seen.add(lbl);
|
||||
labels.push(lbl);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
});
|
||||
|
||||
readonly showSlider = computed(() => this.blockDates().length >= MIN_BLOCKS_FOR_SLIDER);
|
||||
|
||||
/** Selected date range — `null` = show everything. */
|
||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||
|
||||
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
||||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
||||
}
|
||||
|
||||
// ── Tower mutations ────────────────────────────────────────────────────────
|
||||
|
||||
onAddTower(result: TowerSettingsResult): void {
|
||||
this.showAddTower.set(false);
|
||||
this.store.addTower(this.page().id, result.name, result.base_color);
|
||||
}
|
||||
|
||||
onUpdateTower(towerId: string, result: TowerSettingsResult): void {
|
||||
this.store.updateTower(this.page().id, towerId, {
|
||||
name: result.name,
|
||||
base_color: result.base_color,
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteTower(towerId: string): void {
|
||||
this.store.deleteTower(this.page().id, towerId);
|
||||
}
|
||||
|
||||
// ── Block mutations ────────────────────────────────────────────────────────
|
||||
|
||||
onAddBlock(towerId: string, result: BlockPatch): void {
|
||||
this.store.addBlock(
|
||||
this.page().id,
|
||||
towerId,
|
||||
result.tag,
|
||||
result.description,
|
||||
result.is_done,
|
||||
);
|
||||
}
|
||||
|
||||
onSaveBlock(towerId: string, event: { blockId: string; result: BlockPatch }): void {
|
||||
this.store.updateBlock(this.page().id, towerId, event.blockId, event.result);
|
||||
}
|
||||
|
||||
onDeleteBlock(towerId: string, blockId: string): void {
|
||||
this.store.deleteBlock(this.page().id, towerId, blockId);
|
||||
}
|
||||
|
||||
// ── Drag-and-drop + trash ──────────────────────────────────────────────────
|
||||
|
||||
onTowerDragStart(towerId: string): void {
|
||||
this.draggedTowerId = towerId;
|
||||
this.isDragging.set(true);
|
||||
this.dragHappening.emit(true);
|
||||
}
|
||||
|
||||
onTowerDropped(event: CdkDragDrop<unknown>): void {
|
||||
this.isDragging.set(false);
|
||||
this.dragHappening.emit(false);
|
||||
if (this.nearTrashcan && this.draggedTowerId) {
|
||||
// Open confirm dialog instead of deleting immediately.
|
||||
this.confirmDeleteTowerId.set(this.draggedTowerId);
|
||||
} else if (event.previousIndex !== event.currentIndex) {
|
||||
this.store.reorderTowers(this.page().id, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
this.draggedTowerId = null;
|
||||
this.nearTrashcan = false;
|
||||
}
|
||||
|
||||
confirmTowerDelete(): void {
|
||||
const id = this.confirmDeleteTowerId();
|
||||
if (id) this.store.deleteTower(this.page().id, id);
|
||||
this.confirmDeleteTowerId.set(null);
|
||||
}
|
||||
|
||||
cancelTowerDelete(): void {
|
||||
this.confirmDeleteTowerId.set(null);
|
||||
}
|
||||
|
||||
onTrashEnter(): void {
|
||||
this.nearTrashcan = true;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.add('trash-highlight');
|
||||
}
|
||||
|
||||
onTrashLeave(): void {
|
||||
this.nearTrashcan = false;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.remove('trash-highlight');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="$event.stopPropagation() || handleClick()"></div>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
<!-- ​ is the zero width space -->
|
||||
{{ tasks.length == 0 ? '​' : 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
width: 250px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (0–45°): coral/red, orange-red, orange, amber — vivid warm tones
|
||||
// – Skipped 60–180° (yellows + greens) — most read as muddy next to the rose UI accent
|
||||
// – Cool cluster (195–260°): sky-cyan, azure, blue, indigo — clean, distinct from rose
|
||||
// – Purple-rose cluster (280–355°): 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
frontend/src/app/components/shared/icon/icon.component.ts
Normal file
29
frontend/src/app/components/shared/icon/icon.component.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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%`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
frontend/src/app/components/tasks/tasks.component.ts
Normal file
192
frontend/src/app/components/tasks/tasks.component.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
469
frontend/src/app/components/tower/tower.component.ts
Normal file
469
frontend/src/app/components/tower/tower.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
88
frontend/src/app/components/welcome/welcome.component.ts
Normal file
88
frontend/src/app/components/welcome/welcome.component.ts
Normal 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>();
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export interface IColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { IUnique } from './unique';
|
||||
|
||||
export interface IBlock extends IUnique {
|
||||
created: Date;
|
||||
tag: string;
|
||||
isDone: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { IPage } from './page';
|
||||
import { IUnique } from './unique';
|
||||
|
||||
export interface IData extends IUnique {
|
||||
pages: Array<IPage>;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { ITower } from './tower';
|
||||
import { Range } from '../range';
|
||||
import { IUnique } from './unique';
|
||||
|
||||
export interface IPage extends IUnique {
|
||||
name: string;
|
||||
towers: ITower[];
|
||||
|
||||
userData: {
|
||||
hideCreateTowerButton: boolean;
|
||||
defaultDateRange: Range<Date>;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { IBlock } from './block';
|
||||
import { IColor } from '../color';
|
||||
import { IUnique } from './unique';
|
||||
|
||||
export interface ITower extends IUnique {
|
||||
name: string;
|
||||
blocks: IBlock[];
|
||||
baseColor: IColor;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export interface IUnique {
|
||||
id?: string;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export interface Range<T> {
|
||||
from: T;
|
||||
to: T;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export interface ISerializable {
|
||||
serialize(referenceSerializer: (ref: object) => any): object;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { IBlock } from '../interfaces/persistance/block';
|
||||
import { InnerNode, InnerNodeState } from '../store/inner-node';
|
||||
|
||||
export interface BlockState extends IBlock, InnerNodeState {}
|
||||
|
||||
export class Block extends InnerNode implements IBlock, BlockState {
|
||||
readonly tag: string;
|
||||
readonly description: string;
|
||||
readonly isDone: boolean;
|
||||
readonly created: Date;
|
||||
|
||||
constructor(props: IBlock, referenceDeserializer: (from: any) => any = e => e) {
|
||||
super([], props.id);
|
||||
if (props.created.constructor.name !== 'Date') {
|
||||
props.created = new Date(props.created);
|
||||
}
|
||||
this.tag = props.tag;
|
||||
this.description = props.description;
|
||||
this.isDone = props.isDone;
|
||||
this.created = props.created;
|
||||
}
|
||||
|
||||
changeKeys(props: Partial<BlockState>): this {
|
||||
return super.changeKeys<BlockState>(props);
|
||||
}
|
||||
|
||||
serialize(referenceSerializer: (ref: object) => any): IBlock {
|
||||
return {
|
||||
...super.serialize(referenceSerializer),
|
||||
tag: this.tag,
|
||||
description: this.description,
|
||||
isDone: this.isDone,
|
||||
created: this.created
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { InnerNode, InnerNodeState } from '../store/inner-node';
|
||||
import { IData } from '../interfaces/persistance/data';
|
||||
import { Page } from './page';
|
||||
|
||||
export interface DataState extends IData, InnerNodeState {}
|
||||
|
||||
export class Data extends InnerNode implements IData, DataState {
|
||||
constructor(props: IData, referenceDeserializer: (from: any) => any = e => e) {
|
||||
super(props.pages.map(p => new Page(referenceDeserializer(p), referenceDeserializer)), props.id);
|
||||
}
|
||||
|
||||
get pages(): Array<Page> {
|
||||
return this.children as Array<Page>;
|
||||
}
|
||||
|
||||
addPage(name: string) {
|
||||
const page = new Page({
|
||||
name,
|
||||
userData: {
|
||||
hideCreateTowerButton: false,
|
||||
defaultDateRange: {
|
||||
from: null,
|
||||
to: null
|
||||
}
|
||||
},
|
||||
towers: []
|
||||
});
|
||||
this.addChildren([page]);
|
||||
page.addTower();
|
||||
}
|
||||
|
||||
removePage(page: Page) {
|
||||
this.changeKeys<any>({
|
||||
children: this.children.filter(c => c !== page)
|
||||
});
|
||||
}
|
||||
|
||||
serialize(referenceSerializer: (ref: object) => any): IData {
|
||||
return {
|
||||
...super.serialize(referenceSerializer),
|
||||
pages: this.pages.map(referenceSerializer)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { IPage } from '../interfaces/persistance/page';
|
||||
import { Range } from '../interfaces/range';
|
||||
import { Tower } from './tower';
|
||||
import { InnerNode, InnerNodeState } from '../store/inner-node';
|
||||
|
||||
export interface PageState extends InnerNodeState, IPage {
|
||||
towers: Array<Tower>;
|
||||
}
|
||||
|
||||
export class Page extends InnerNode implements IPage, PageState {
|
||||
readonly name: string;
|
||||
|
||||
readonly userData: {
|
||||
hideCreateTowerButton: boolean;
|
||||
defaultDateRange: Range<Date>;
|
||||
};
|
||||
|
||||
constructor(props: IPage, referenceDeserializer: (from: any) => any = e => e) {
|
||||
super(props.towers.map(t => new Tower(referenceDeserializer(t), referenceDeserializer)), props.id);
|
||||
this.name = props.name;
|
||||
this.userData = props.userData;
|
||||
}
|
||||
|
||||
get towers(): Array<Tower> {
|
||||
return this.children as Array<Tower>;
|
||||
}
|
||||
|
||||
changeProps(props: Partial<PageState>): this {
|
||||
if (props.hasOwnProperty('towers')) {
|
||||
props.children = props.towers;
|
||||
delete props.towers;
|
||||
}
|
||||
return this.changeKeys<PageState>(props);
|
||||
}
|
||||
|
||||
setHideCreateTowerButton(value: boolean) {
|
||||
this.changeProps({
|
||||
userData: {
|
||||
...this.userData,
|
||||
hideCreateTowerButton: value
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
moveTower({ previousIndex, currentIndex }: { previousIndex: number; currentIndex: number }) {
|
||||
if (previousIndex === currentIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const towers = [...this.towers];
|
||||
const tower = towers[previousIndex];
|
||||
towers.splice(previousIndex, 1);
|
||||
towers.splice(currentIndex, 0, tower);
|
||||
|
||||
this.changeProps({
|
||||
towers
|
||||
});
|
||||
}
|
||||
|
||||
changeName(to: string) {
|
||||
this.changeProps({
|
||||
name: to
|
||||
});
|
||||
}
|
||||
|
||||
addTower(name = '') {
|
||||
let hue;
|
||||
do {
|
||||
hue = Math.random() * 360;
|
||||
} while (30 <= hue && hue <= 200);
|
||||
|
||||
this.addChildren([
|
||||
new Tower({
|
||||
name,
|
||||
blocks: [],
|
||||
baseColor: { h: hue, s: 100, l: 50 }
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
removeTower(tower: Tower) {
|
||||
this.changeProps({
|
||||
towers: this.towers.filter(t => t !== tower)
|
||||
});
|
||||
}
|
||||
|
||||
serialize(referenceSerializer: (ref: object) => any): IPage {
|
||||
return {
|
||||
...super.serialize(referenceSerializer),
|
||||
name: this.name,
|
||||
userData: this.userData,
|
||||
towers: this.towers.map(referenceSerializer)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { ITower } from '../interfaces/persistance/tower';
|
||||
import { lighten } from '../utils/color';
|
||||
import { Block } from './block';
|
||||
import { hash } from '../utils/hash';
|
||||
import { IColor } from '../interfaces/color';
|
||||
import { InnerNode, InnerNodeState } from '../store/inner-node';
|
||||
|
||||
export type ColoredBlock = Block & { color: IColor };
|
||||
|
||||
export interface TowerState extends ITower, InnerNodeState {
|
||||
blocks: Array<Block>;
|
||||
}
|
||||
|
||||
export class Tower extends InnerNode implements ITower, TowerState {
|
||||
readonly name: string;
|
||||
readonly baseColor: IColor;
|
||||
tags: string[];
|
||||
coloredBlocks: Array<ColoredBlock>;
|
||||
|
||||
constructor(props: ITower, referenceDeserializer: (from: any) => any = e => e) {
|
||||
super(props.blocks.map(b => new Block(referenceDeserializer(b), referenceDeserializer)), props.id);
|
||||
this.name = props.name;
|
||||
this.baseColor = props.baseColor;
|
||||
this.onAfterClone();
|
||||
}
|
||||
|
||||
get blocks(): Array<Block> {
|
||||
return this.children as Array<Block>;
|
||||
}
|
||||
|
||||
changeKeys(props: Partial<TowerState>): this {
|
||||
if (props.hasOwnProperty('blocks')) {
|
||||
props.children = props.blocks;
|
||||
delete props.blocks;
|
||||
}
|
||||
return super.changeKeys<TowerState>(props);
|
||||
}
|
||||
|
||||
addBlock(props: { tag: string; description: string; isDone: boolean }) {
|
||||
this.addChildren([
|
||||
new Block({
|
||||
created: new Date(),
|
||||
...props
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
changeName(name: string) {
|
||||
this.changeKeys({ name });
|
||||
}
|
||||
|
||||
getColorOfTag(tag: string): IColor {
|
||||
return lighten((hash(tag) - 0.5) * 50, this.baseColor);
|
||||
}
|
||||
|
||||
serialize(referenceSerializer: (ref: object) => any): ITower {
|
||||
return {
|
||||
...super.serialize(referenceSerializer),
|
||||
name: this.name,
|
||||
baseColor: this.baseColor,
|
||||
blocks: this.blocks.map(referenceSerializer)
|
||||
};
|
||||
}
|
||||
|
||||
protected onAfterClone() {
|
||||
this.blocks.sort((a, b) => {
|
||||
return a.created.getTime() - b.created.getTime();
|
||||
});
|
||||
|
||||
this.coloredBlocks = this.blocks.map(b => {
|
||||
const coloredBlock = b as ColoredBlock;
|
||||
coloredBlock.color = this.getColorOfTag(b.tag);
|
||||
return coloredBlock;
|
||||
});
|
||||
|
||||
this.tags = [];
|
||||
for (const block of this.blocks) {
|
||||
if (!this.tags.includes(block.tag)) {
|
||||
this.tags.push(block.tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
frontend/src/app/models/index.ts
Normal file
44
frontend/src/app/models/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export interface HslColor {
|
||||
h: number; // 0-1
|
||||
s: number; // 0-1
|
||||
l: number; // 0-1
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Tower {
|
||||
id: string;
|
||||
name: string;
|
||||
base_color: HslColor;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
name: string;
|
||||
hide_create_tower_button: boolean;
|
||||
keep_tasks_open: boolean;
|
||||
default_date_from: number | null;
|
||||
default_date_to: number | null;
|
||||
towers: Tower[];
|
||||
}
|
||||
|
||||
export interface TreeDto {
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
export type SaveStatus =
|
||||
| 'idle'
|
||||
| 'saving'
|
||||
| 'saved'
|
||||
| 'retrying'
|
||||
| 'error' // generic / network — will keep trying
|
||||
| 'too-large' // 413 — payload exceeds the server cap, won't retry
|
||||
| 'rate-limited' // 429 — will retry after Retry-After
|
||||
| 'invalid'; // 400 — server rejected the body, won't retry
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { IColor } from '../interfaces/color';
|
||||
import { toHslString } from '../utils/color';
|
||||
|
||||
@Pipe({
|
||||
name: 'color'
|
||||
})
|
||||
export class ColorPipe implements PipeTransform {
|
||||
transform(color: IColor, args?: any): string {
|
||||
return toHslString(color);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'formatDate',
|
||||
pure: false
|
||||
})
|
||||
export class FormatDatePipe implements PipeTransform {
|
||||
transform(value: Date): string {
|
||||
const now = new Date();
|
||||
|
||||
const years = Math.floor(now.getFullYear() - value.getFullYear());
|
||||
const months = Math.floor(now.getMonth() - value.getMonth());
|
||||
const days = Math.floor(now.getDay() - value.getDay());
|
||||
const minutes = Math.floor(now.getMinutes() - value.getMinutes());
|
||||
const seconds = Math.floor(now.getSeconds() - value.getSeconds());
|
||||
|
||||
if (years === 1) {
|
||||
return 'a year ago';
|
||||
} else if (years > 1) {
|
||||
return `${years} years ago`;
|
||||
}
|
||||
|
||||
if (months === 1) {
|
||||
return 'a month ago';
|
||||
} else if (months > 1) {
|
||||
return `${months} months ago`;
|
||||
}
|
||||
|
||||
if (days === 1) {
|
||||
return 'a day ago';
|
||||
} else if (days > 1) {
|
||||
return `${days} days ago`;
|
||||
}
|
||||
|
||||
if (minutes === 1) {
|
||||
return 'a minute ago';
|
||||
} else if (minutes > 1) {
|
||||
return `${minutes} minutes ago`;
|
||||
}
|
||||
|
||||
if (seconds === 1) {
|
||||
return 'just now';
|
||||
} else if (seconds > 1) {
|
||||
return `${seconds} seconds ago`;
|
||||
}
|
||||
|
||||
return 'just now';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +1,35 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Unique } from '../store/unique';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TreeDto } from '../models';
|
||||
|
||||
const API_URI = 'https://store.schmelczer.dev/api/store/';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
constructor(private http: HttpClient) {}
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private static getAuthorizationHeader(id: string): HttpHeaders {
|
||||
return new HttpHeaders().set('Authorization', `life-towers-v3 ${id}`);
|
||||
health(): Promise<{ status: string }> {
|
||||
return firstValueFrom(this.http.get<{ status: string }>('/api/v1/health'));
|
||||
}
|
||||
|
||||
async track(id: string): Promise<void> {
|
||||
await this.http.post(`${API_URI}me`, {}, { headers: ApiService.getAuthorizationHeader(id) }).toPromise();
|
||||
register(token: string): Promise<{ user_id: string }> {
|
||||
return firstValueFrom(
|
||||
this.http.post<{ user_id: string }>('/api/v1/register', { token }),
|
||||
);
|
||||
}
|
||||
|
||||
async register(id: string): Promise<void> {
|
||||
await this.http.post(API_URI, { token: id }).toPromise();
|
||||
getData(token: string): Promise<TreeDto> {
|
||||
return firstValueFrom(
|
||||
this.http.get<TreeDto>('/api/v1/data', { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
async getObject(userId: string, objectId: string): Promise<Unique> {
|
||||
return await this.http
|
||||
.get<Unique>(`${API_URI}me/${objectId}`, { headers: ApiService.getAuthorizationHeader(userId) })
|
||||
.toPromise();
|
||||
putData(token: string, tree: TreeDto): Promise<void> {
|
||||
return firstValueFrom(
|
||||
this.http.put<void>('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
async postObject(userId: string, objectId: string, serializedObject: string): Promise<void> {
|
||||
await this.http
|
||||
.post(
|
||||
`${API_URI}me/${objectId}`,
|
||||
{ data: serializedObject },
|
||||
{ headers: ApiService.getAuthorizationHeader(userId) }
|
||||
)
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
async getRootId(userId: string): Promise<string> {
|
||||
return await this.http
|
||||
// @ts-ignore
|
||||
.get<string>(`${API_URI}me/root`, { headers: ApiService.getAuthorizationHeader(userId), responseType: 'text' })
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
async setRootId(userId: string, rootId: string): Promise<void> {
|
||||
await this.http
|
||||
.put(`${API_URI}me/root`, { root_id: rootId }, { headers: ApiService.getAuthorizationHeader(userId) })
|
||||
.toPromise();
|
||||
private authHeaders(token: string): HttpHeaders {
|
||||
return new HttpHeaders({ Authorization: `Bearer ${token}` });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
frontend/src/app/services/api.service.vitest.ts
Normal file
28
frontend/src/app/services/api.service.vitest.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock environment
|
||||
vi.mock('../../environments/environment', () => ({
|
||||
environment: { apiBase: 'http://test-api', production: false },
|
||||
}));
|
||||
|
||||
describe('ApiService URL patterns', () => {
|
||||
const baseUrl = 'http://test-api';
|
||||
|
||||
it('constructs correct health URL', () => {
|
||||
expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health');
|
||||
});
|
||||
|
||||
it('constructs correct register URL', () => {
|
||||
expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register');
|
||||
});
|
||||
|
||||
it('constructs correct data URL', () => {
|
||||
expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data');
|
||||
});
|
||||
|
||||
it('formats Authorization header correctly', () => {
|
||||
const token = 'abc-def-123';
|
||||
const header = `Bearer ${token}`;
|
||||
expect(header).toBe('Bearer abc-def-123');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
interface Subscriber {
|
||||
callback: () => void;
|
||||
object: object;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CancelService {
|
||||
private subscribers: Subscriber[] = [];
|
||||
|
||||
subscribe(object: object, callback: () => void) {
|
||||
this.subscribers.push({
|
||||
object,
|
||||
callback
|
||||
});
|
||||
}
|
||||
|
||||
cancelAllExcept(except: object) {
|
||||
this.subscribers.filter(s => s.object !== except).map(s => s.callback());
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
this.subscribers.map(s => s.callback());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Root } from '../store/root';
|
||||
import { MapStoreService } from './map-store.service';
|
||||
import { Data } from '../model/data';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DataService extends Root<Data> {
|
||||
private shouldSave = true;
|
||||
constructor(private store: MapStoreService) {
|
||||
super();
|
||||
|
||||
this.store.data.subscribe(d => {
|
||||
if (d) {
|
||||
this.shouldSave = false;
|
||||
this.changeKeys({ children: [d] });
|
||||
}
|
||||
});
|
||||
|
||||
this.children$.subscribe(_ => {
|
||||
this.log();
|
||||
});
|
||||
|
||||
this.children$.subscribe(data => {
|
||||
if (data && data.length && data[0]) {
|
||||
if (!this.shouldSave) {
|
||||
this.shouldSave = true;
|
||||
return;
|
||||
}
|
||||
this.store.save(data[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import * as uuid from 'uuid';
|
||||
import { Data } from '../model/data';
|
||||
import { ITower } from '../interfaces/persistance/tower';
|
||||
import { IPage } from '../interfaces/persistance/page';
|
||||
import { IData } from '../interfaces/persistance/data';
|
||||
import { IUnique } from '../interfaces/persistance/unique';
|
||||
import { ApiService } from './api.service';
|
||||
import { Unique } from '../store/unique';
|
||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'life-towers.data.v.3';
|
||||
|
||||
interface LifeTowersData {
|
||||
token: string;
|
||||
root: string;
|
||||
objects: {
|
||||
[id: string]: IUnique;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MapStoreService {
|
||||
private state: LifeTowersData;
|
||||
private canSaveTrigger: () => void;
|
||||
private canSave = new Promise(r => (this.canSaveTrigger = r));
|
||||
private dataToSave: Data;
|
||||
|
||||
private saveEverything = false;
|
||||
|
||||
private readonly _data: BehaviorSubject<Data> = new BehaviorSubject(null);
|
||||
readonly data: Observable<Data> = this._data.asObservable();
|
||||
|
||||
constructor(private api: ApiService) {
|
||||
const storedData: string = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
|
||||
if (storedData) {
|
||||
this.state = JSON.parse(storedData);
|
||||
this.initWithLoad().catch();
|
||||
} else {
|
||||
this.initWithRegister().catch();
|
||||
}
|
||||
this.api.track(this.state.token).catch();
|
||||
}
|
||||
|
||||
private static getSeed(): LifeTowersData {
|
||||
const towerId = uuid.v4();
|
||||
const tower: ITower = {
|
||||
id: towerId,
|
||||
name: null,
|
||||
blocks: [],
|
||||
baseColor: { h: 0, s: 100, l: 50 }
|
||||
};
|
||||
|
||||
const pageId = uuid.v4();
|
||||
const page: IPage = {
|
||||
id: pageId,
|
||||
name: 'My first page',
|
||||
towers: [towerId],
|
||||
userData: {
|
||||
hideCreateTowerButton: false,
|
||||
defaultDateRange: {
|
||||
from: null,
|
||||
to: null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataId = uuid.v4();
|
||||
const data: IData = {
|
||||
id: dataId,
|
||||
pages: [pageId]
|
||||
};
|
||||
|
||||
return {
|
||||
token: uuid.v4(),
|
||||
root: dataId,
|
||||
objects: {
|
||||
[dataId]: data,
|
||||
[pageId]: page,
|
||||
[towerId]: tower
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
save(root: Data) {
|
||||
this.dataToSave = root;
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.dataToSave === root) {
|
||||
this.realSave(root).catch();
|
||||
}
|
||||
}, 750);
|
||||
}
|
||||
|
||||
private get root(): Data {
|
||||
return new Data(this.state.objects[this.state.root] as IData, id => this.state.objects[id]);
|
||||
}
|
||||
|
||||
get userToken(): string {
|
||||
return this.state.token;
|
||||
}
|
||||
|
||||
set userToken(value: string) {
|
||||
this.state.token = value;
|
||||
this.initWithLoad().catch();
|
||||
}
|
||||
|
||||
private async realSave(root: Data): Promise<void> {
|
||||
await this.canSave;
|
||||
|
||||
const waiting: Array<Unique> = [root];
|
||||
const referenceSerializer = (e: Unique): string => {
|
||||
waiting.push(e);
|
||||
return e.id;
|
||||
};
|
||||
|
||||
while (waiting.length > 0) {
|
||||
const candidate = waiting.pop();
|
||||
if (!this.saveEverything && this.state.objects.hasOwnProperty(candidate.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serialized = candidate.serialize(referenceSerializer);
|
||||
|
||||
this.state.objects[candidate.id] = serialized;
|
||||
this.api.postObject(this.state.token, candidate.id, JSON.stringify(serialized)).catch();
|
||||
}
|
||||
|
||||
this.api.setRootId(this.state.token, root.id).catch();
|
||||
this.state.root = root.id;
|
||||
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.state));
|
||||
}
|
||||
|
||||
private async initWithRegister(id?: string) {
|
||||
this.state = MapStoreService.getSeed();
|
||||
if (id) {
|
||||
this.state.token = id;
|
||||
}
|
||||
this._data.next(this.root);
|
||||
|
||||
await this.api.register(this.state.token).catch();
|
||||
this.canSaveTrigger();
|
||||
|
||||
this.saveEverything = true;
|
||||
await this.realSave(this.root);
|
||||
this.saveEverything = false;
|
||||
}
|
||||
|
||||
private async initWithLoad() {
|
||||
this.canSave = new Promise(r => (this.canSaveTrigger = r));
|
||||
|
||||
let realRoot: string;
|
||||
try {
|
||||
realRoot = await this.api.getRootId(this.state.token).catch();
|
||||
} catch {
|
||||
this.initWithRegister(this.state.token).catch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.root !== realRoot) {
|
||||
this.state.root = realRoot;
|
||||
const root = await this.api.getObject(this.state.token, realRoot);
|
||||
this.state.objects[this.state.root] = root;
|
||||
|
||||
const getUnknowns = async (element: any) => {
|
||||
const childrenAliases = ['pages', 'towers', 'blocks'];
|
||||
|
||||
for (const childrenAlias of childrenAliases) {
|
||||
if (element.hasOwnProperty(childrenAlias)) {
|
||||
for (const p of element[childrenAlias]) {
|
||||
if (!this.state.objects.hasOwnProperty(p)) {
|
||||
const unknown = await this.api.getObject(this.state.token, p);
|
||||
this.state.objects[p] = unknown;
|
||||
await getUnknowns(unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await getUnknowns(root);
|
||||
}
|
||||
this._data.next(this.root);
|
||||
this.canSaveTrigger();
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/services/modal-state.service.ts
Normal file
25
frontend/src/app/services/modal-state.service.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Shared counter of currently-open modals. Each `lt-modal` increments on
|
||||
* mount and decrements on destroy. Consumers read `anyOpen` to react.
|
||||
*
|
||||
* Used by `page.component` to disable tower drag-and-drop while any modal
|
||||
* (block-edit carousel, page-settings, tower-settings, confirm-delete,
|
||||
* settings) is on screen — otherwise the user can drag towers from behind
|
||||
* the open card.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ModalStateService {
|
||||
private readonly _openCount = signal(0);
|
||||
|
||||
readonly anyOpen = computed(() => this._openCount() > 0);
|
||||
|
||||
open(): void {
|
||||
this._openCount.update((n) => n + 1);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._openCount.update((n) => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Tower } from '../model/tower';
|
||||
import { top } from '../utils/top';
|
||||
import { CancelService } from './cancel.service';
|
||||
import { Page } from '../model/page';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Block } from '../model/block';
|
||||
import { Data } from '../model/data';
|
||||
|
||||
export enum ModalType {
|
||||
blocks,
|
||||
settings,
|
||||
removeTower,
|
||||
removePage,
|
||||
getStarted
|
||||
}
|
||||
|
||||
interface Modal {
|
||||
type: ModalType;
|
||||
input: any;
|
||||
resolve: (output: any) => void;
|
||||
reject: () => void;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ModalService {
|
||||
private modalStack: Modal[] = [];
|
||||
|
||||
constructor(private cancelService: CancelService) {}
|
||||
|
||||
showBlocks(input: { tower$: Observable<Tower>; onlyDone: boolean; startBlock?: Block }): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(input, ModalType.blocks);
|
||||
}
|
||||
|
||||
showSettings(options: { page$: Observable<Page>; data$: Observable<Data> }): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(options, ModalType.settings);
|
||||
}
|
||||
|
||||
showRemoveTower(tower: Tower): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(tower, ModalType.removeTower);
|
||||
}
|
||||
|
||||
showRemovePage(name: string): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(name, ModalType.removePage);
|
||||
}
|
||||
|
||||
get active(): Modal {
|
||||
return top(this.modalStack);
|
||||
}
|
||||
|
||||
submit(output?: any) {
|
||||
const modal = this.modalStack.pop();
|
||||
if (modal) {
|
||||
modal.resolve(output);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
const modal = this.modalStack.pop();
|
||||
if (modal) {
|
||||
modal.reject();
|
||||
}
|
||||
}
|
||||
|
||||
private createPromiseAndPushToStack(input: any, type: ModalType): Promise<any> {
|
||||
this.cancelService.cancelAll();
|
||||
|
||||
const modal = {
|
||||
input,
|
||||
type,
|
||||
resolve: () => {},
|
||||
reject: () => {}
|
||||
};
|
||||
|
||||
const modalPromise = new Promise((resolve, reject) => {
|
||||
modal.resolve = resolve;
|
||||
modal.reject = reject;
|
||||
});
|
||||
|
||||
this.modalStack.push(modal);
|
||||
return modalPromise;
|
||||
}
|
||||
}
|
||||
585
frontend/src/app/services/store.service.ts
Normal file
585
frontend/src/app/services/store.service.ts
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
||||
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
const DEBOUNCE_MS = 750;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
// RFC 4122 v4 UUID. Prefers crypto.randomUUID (secure contexts only) and
|
||||
// falls back to crypto.getRandomValues — which works on plain http origins
|
||||
// behind a non-localhost reverse proxy too.
|
||||
function uuidV4(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const b = new Uint8Array(16);
|
||||
crypto.getRandomValues(b);
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function isUuidV4(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
// localStorage can throw (private mode, quota exceeded, disabled). All
|
||||
// access goes through these helpers so a transient failure never bubbles
|
||||
// up and breaks app init.
|
||||
function safeGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function safeSet(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingPut {
|
||||
token: string;
|
||||
tree: TreeDto;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StoreService implements OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
private readonly _pages = signal<Page[]>([]);
|
||||
private readonly _saveStatus = signal<SaveStatus>('idle');
|
||||
private readonly _token = signal<string>('');
|
||||
private readonly _loading = signal<boolean>(true);
|
||||
|
||||
readonly pages = this._pages.asReadonly();
|
||||
readonly saveStatus = this._saveStatus.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly token = this._token.asReadonly();
|
||||
|
||||
readonly pageCount = computed(() => this._pages().length);
|
||||
|
||||
// ── Debounce / retry ───────────────────────────────────────────────────────
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushInFlight = false;
|
||||
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
||||
private dirtyDuringFlush = false;
|
||||
|
||||
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
||||
private readonly storageListener = (e: StorageEvent) => {
|
||||
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
||||
// Another tab switched accounts. Re-init with the new token instead of
|
||||
// continuing to sync the old account's state to the new one.
|
||||
this._token.set(e.newValue);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this.cancelPendingWrites();
|
||||
void this.init();
|
||||
} else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) {
|
||||
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
|
||||
// (to avoid clobbering our own state with the other tab's older view).
|
||||
try {
|
||||
const tree: TreeDto = JSON.parse(e.newValue);
|
||||
this._pages.set(tree.pages);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Single-flight init ─────────────────────────────────────────────────────
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
async init(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this.doInit().finally(() => {
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInit(): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
// (idempotent — adding the same listener twice is a no-op)
|
||||
window.addEventListener('storage', this.storageListener);
|
||||
}
|
||||
|
||||
let stored = safeGet(TOKEN_KEY);
|
||||
if (stored && !isUuidV4(stored)) {
|
||||
// Garbage in localStorage from a buggy past version — refuse it.
|
||||
stored = null;
|
||||
}
|
||||
const token = stored ?? uuidV4();
|
||||
if (!stored) {
|
||||
safeSet(TOKEN_KEY, token);
|
||||
}
|
||||
this._token.set(token);
|
||||
|
||||
if (!stored) {
|
||||
try {
|
||||
await this.api.register(token);
|
||||
} catch {
|
||||
// Non-fatal; the 401 path below will re-attempt registration.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
if (status === 401) {
|
||||
// Token unknown to server — re-register (idempotent) and retry.
|
||||
try {
|
||||
await this.api.register(token);
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
} catch {
|
||||
this.loadFromCache();
|
||||
}
|
||||
} else {
|
||||
this.loadFromCache();
|
||||
}
|
||||
} finally {
|
||||
this._loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a freshly-fetched server tree. If the server is empty but our local
|
||||
* cache holds data, the cache wins and we schedule a push — otherwise the
|
||||
* "server forgot me" recovery would silently wipe offline edits.
|
||||
*/
|
||||
private adoptServerTree(tree: TreeDto): void {
|
||||
if (tree.pages.length === 0) {
|
||||
const cached = safeGet(CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const cachedTree: TreeDto = JSON.parse(cached);
|
||||
if (cachedTree.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to server-empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
this._pages.set(tree.pages);
|
||||
this.updateCache(tree);
|
||||
}
|
||||
|
||||
private loadFromCache(): void {
|
||||
const raw = safeGet(CACHE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const tree: TreeDto = JSON.parse(raw);
|
||||
this._pages.set(tree.pages);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateCache(tree: TreeDto): void {
|
||||
safeSet(CACHE_KEY, JSON.stringify(tree));
|
||||
}
|
||||
|
||||
private cancelPendingWrites(): void {
|
||||
if (this.debounceTimer !== null) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
if (this.retryTimer !== null) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
this.dirtyDuringFlush = false;
|
||||
}
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
addPage(name: string): void {
|
||||
const page: Page = {
|
||||
id: uuidV4(),
|
||||
name,
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [],
|
||||
};
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updatePage(id: string, patch: Partial<Omit<Page, 'id' | 'towers'>>): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === id ? { ...p, ...patch } : p)),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deletePage(id: string): void {
|
||||
this._pages.update((pages) => pages.filter((p) => p.id !== id));
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
reorderPages(fromIndex: number, toIndex: number): void {
|
||||
this._pages.update((pages) => {
|
||||
const arr = [...pages];
|
||||
const [item] = arr.splice(fromIndex, 1);
|
||||
arr.splice(toIndex, 0, item);
|
||||
return arr;
|
||||
});
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||
const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] };
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updateTower(pageId: string, towerId: string, patch: Partial<Omit<Tower, 'id' | 'blocks'>>): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? { ...p, towers: p.towers.map((t) => (t.id === towerId ? { ...t, ...patch } : t)) }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deleteTower(pageId: string, towerId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId ? { ...p, towers: p.towers.filter((t) => t.id !== towerId) } : p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => {
|
||||
if (p.id !== pageId) return p;
|
||||
const towers = [...p.towers];
|
||||
const [item] = towers.splice(fromIndex, 1);
|
||||
towers.splice(toIndex, 0, item);
|
||||
return { ...p, towers };
|
||||
}),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
addBlock(
|
||||
pageId: string,
|
||||
towerId: string,
|
||||
tag: string,
|
||||
description: string,
|
||||
is_done = false,
|
||||
): void {
|
||||
const block: Block = {
|
||||
id: uuidV4(),
|
||||
tag,
|
||||
description,
|
||||
is_done,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updateBlock(
|
||||
pageId: string,
|
||||
towerId: string,
|
||||
blockId: string,
|
||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||
): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deleteBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
toggleBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) =>
|
||||
b.id === blockId ? { ...b, is_done: !b.is_done } : b,
|
||||
),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different user's token. Any pending writes for the OLD
|
||||
* account must be cancelled first — otherwise a queued PUT could fire
|
||||
* against the NEW account with the old account's data (or vice versa,
|
||||
* if the timing flipped).
|
||||
*/
|
||||
switchToken(newToken: string): void {
|
||||
if (!isUuidV4(newToken)) return;
|
||||
this.cancelPendingWrites();
|
||||
safeSet(TOKEN_KEY, newToken);
|
||||
this._token.set(newToken);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this._saveStatus.set('idle');
|
||||
void this.init();
|
||||
}
|
||||
|
||||
// ── Save / sync ────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.flushInFlight) {
|
||||
// A save is already happening. Mark dirty so we re-flush when it finishes.
|
||||
this.dirtyDuringFlush = true;
|
||||
return;
|
||||
}
|
||||
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
void this.runFlush();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* One save attempt with bounded retries. Captures the token and tree
|
||||
* snapshot up front so a mid-flight switchToken can't redirect this
|
||||
* write to a different account.
|
||||
*/
|
||||
private async runFlush(): Promise<void> {
|
||||
if (this.flushInFlight) {
|
||||
this.dirtyDuringFlush = true;
|
||||
return;
|
||||
}
|
||||
const token = this._token();
|
||||
if (!token) return;
|
||||
|
||||
this.flushInFlight = true;
|
||||
this.dirtyDuringFlush = false;
|
||||
|
||||
// Cancel any pending retry — runFlush() supersedes it.
|
||||
if (this.retryTimer !== null) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.attempt({ token, tree: { pages: this._pages() } }, 0);
|
||||
} finally {
|
||||
this.flushInFlight = false;
|
||||
// Coalesce mutations that arrived during the flush into a fresh save.
|
||||
if (this.dirtyDuringFlush) {
|
||||
this.dirtyDuringFlush = false;
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async attempt(put: PendingPut, attempt: number): Promise<void> {
|
||||
this._saveStatus.set(attempt === 0 ? 'saving' : 'retrying');
|
||||
try {
|
||||
await this.api.putData(put.token, put.tree);
|
||||
this._saveStatus.set('saved');
|
||||
this.updateCache(put.tree);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
const headers = (err as { headers?: { get(name: string): string | null } })?.headers;
|
||||
|
||||
// Permanent failures: no point retrying.
|
||||
if (status === 400) {
|
||||
this._saveStatus.set('invalid');
|
||||
return;
|
||||
}
|
||||
if (status === 413) {
|
||||
this._saveStatus.set('too-large');
|
||||
return;
|
||||
}
|
||||
|
||||
// 401 mid-PUT: server forgot us. Re-register (idempotent) and retry.
|
||||
if (status === 401) {
|
||||
try {
|
||||
await this.api.register(put.token);
|
||||
} catch {
|
||||
// fall through to retry/backoff
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
this._saveStatus.set('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Honor server's Retry-After when present (429 in particular).
|
||||
let delayMs = Math.min(1000 * 2 ** attempt, 30000);
|
||||
if (status === 429) {
|
||||
this._saveStatus.set('rate-limited');
|
||||
const ra = headers?.get('Retry-After') ?? headers?.get('retry-after');
|
||||
if (ra) {
|
||||
const seconds = parseInt(ra, 10);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
delayMs = Math.min(seconds * 1000, 60_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
resolve();
|
||||
}, delayMs);
|
||||
});
|
||||
|
||||
// The token may have changed during the wait — re-check before retrying.
|
||||
if (this._token() !== put.token) return;
|
||||
// Re-snapshot the latest pages so the retry pushes current state.
|
||||
await this.attempt({ token: put.token, tree: { pages: this._pages() } }, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Example data ──────────────────────────────────────────────────────────
|
||||
|
||||
loadExample(): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const page: Page = {
|
||||
id: uuidV4(),
|
||||
name: 'Hobbies',
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: true,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [
|
||||
this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [
|
||||
{ tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 },
|
||||
{ tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 },
|
||||
{ tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 },
|
||||
{ tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 },
|
||||
{ tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 },
|
||||
]),
|
||||
this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [
|
||||
{ tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 },
|
||||
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 },
|
||||
{ tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 },
|
||||
{ tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 },
|
||||
]),
|
||||
this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
|
||||
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 },
|
||||
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
|
||||
{ tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 },
|
||||
{ tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 },
|
||||
{ tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 },
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private makeExampleTower(
|
||||
name: string,
|
||||
base_color: HslColor,
|
||||
nowSec: number,
|
||||
blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>,
|
||||
): Tower {
|
||||
return {
|
||||
id: uuidV4(),
|
||||
name,
|
||||
base_color,
|
||||
blocks: blocks.map((b) => ({
|
||||
id: uuidV4(),
|
||||
tag: b.tag,
|
||||
description: b.desc,
|
||||
is_done: b.done,
|
||||
created_at: nowSec - Math.floor(b.ageHrs * 3600),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingWrites();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('storage', this.storageListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
393
frontend/src/app/services/store.service.vitest.ts
Normal file
393
frontend/src/app/services/store.service.vitest.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { StoreService } from './store.service';
|
||||
import { ApiService } from './api.service';
|
||||
import type { TreeDto } from '../models';
|
||||
|
||||
// ── localStorage stub ────────────────────────────────────────────────────────
|
||||
const storage: Record<string, string> = {};
|
||||
let storageThrowsOnSet = false;
|
||||
const localStorageStub = {
|
||||
getItem: (k: string) => storage[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
if (storageThrowsOnSet) throw new Error('QuotaExceededError');
|
||||
storage[k] = v;
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete storage[k];
|
||||
},
|
||||
clear: () => Object.keys(storage).forEach((k) => delete storage[k]),
|
||||
key: (i: number) => Object.keys(storage)[i] ?? null,
|
||||
get length() {
|
||||
return Object.keys(storage).length;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorageStub,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// ── crypto stub for tests that need deterministic UUIDs ─────────────────────
|
||||
const realCrypto = globalThis.crypto;
|
||||
function withFixedUuid<T>(uuid: string, fn: () => T): T {
|
||||
const stub = {
|
||||
randomUUID: () => uuid,
|
||||
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'crypto', { value: stub, configurable: true });
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock ApiService factory ──────────────────────────────────────────────────
|
||||
interface MockApi {
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
getData: ReturnType<typeof vi.fn>;
|
||||
putData: ReturnType<typeof vi.fn>;
|
||||
health: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeMockApi(): MockApi {
|
||||
return {
|
||||
health: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
register: vi.fn().mockResolvedValue({ user_id: 'u' }),
|
||||
getData: vi.fn().mockResolvedValue({ pages: [] } satisfies TreeDto),
|
||||
putData: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function configure(api: MockApi): StoreService {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: ApiService, useValue: api },
|
||||
StoreService,
|
||||
],
|
||||
});
|
||||
return TestBed.inject(StoreService);
|
||||
}
|
||||
|
||||
function mkPage(name: string): TreeDto['pages'][number] {
|
||||
return {
|
||||
id: FIXED_UUID,
|
||||
name,
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// HttpErrorResponse-compatible shape for rejected promises.
|
||||
function httpError(status: number, headers: Record<string, string> = {}) {
|
||||
const headersObj = {
|
||||
get: (n: string) =>
|
||||
headers[n] ?? headers[n.toLowerCase()] ?? headers[n.toUpperCase()] ?? null,
|
||||
};
|
||||
const err: { status: number; headers: typeof headersObj } = { status, headers: headersObj };
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('StoreService', () => {
|
||||
beforeEach(() => {
|
||||
localStorageStub.clear();
|
||||
storageThrowsOnSet = false;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
it('mints + persists a UUIDv4 token and calls register on first launch', async () => {
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await store.init();
|
||||
});
|
||||
|
||||
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses an existing stored token without re-registering', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(api.register).not.toHaveBeenCalled();
|
||||
expect(store.token()).toBe(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
|
||||
storage[TOKEN_KEY] = 'not-a-uuid';
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await store.init();
|
||||
});
|
||||
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('on 401 from getData, re-registers the SAME token (idempotent) and retries', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.getData
|
||||
.mockRejectedValueOnce(httpError(401))
|
||||
.mockResolvedValueOnce({ pages: [mkPage('after-401')] });
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(api.register).toHaveBeenCalledTimes(1);
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledTimes(2);
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('after-401');
|
||||
});
|
||||
|
||||
it('falls back to cache on non-401 network error', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('cached')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
api.getData.mockRejectedValue(httpError(0));
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('cached');
|
||||
});
|
||||
|
||||
it('keeps local cache when server returns empty but cache has data', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('offline-edit')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('offline-edit');
|
||||
});
|
||||
|
||||
it('init() doesn\'t crash if localStorage.setItem throws (private mode)', async () => {
|
||||
storageThrowsOnSet = true;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await expect(store.init()).resolves.toBeUndefined();
|
||||
});
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('init() is single-flight — concurrent calls return the same promise', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveGet: ((v: TreeDto) => void) | null = null;
|
||||
api.getData.mockReturnValue(new Promise<TreeDto>((res) => (resolveGet = res)));
|
||||
const store = configure(api);
|
||||
|
||||
const p1 = store.init();
|
||||
const p2 = store.init();
|
||||
resolveGet!({ pages: [] });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(api.getData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── Debounced save ─────────────────────────────────────────────────────────
|
||||
|
||||
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('A');
|
||||
store.addPage('B');
|
||||
store.addPage('C');
|
||||
|
||||
expect(api.putData).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
const [, tree] = api.putData.mock.calls[0];
|
||||
expect((tree as TreeDto).pages).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('mutation while a save is in-flight triggers a follow-up save', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveFirstPut: (() => void) | null = null;
|
||||
api.putData
|
||||
.mockReturnValueOnce(new Promise<void>((res) => (resolveFirstPut = () => res())))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('first');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Mutate while first PUT is still hanging.
|
||||
store.addPage('second');
|
||||
|
||||
// Finish the first save → follow-up should be scheduled.
|
||||
resolveFirstPut!();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
const lastTree = api.putData.mock.calls[1][1] as TreeDto;
|
||||
expect(lastTree.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it('marks status "too-large" on 413 and does NOT retry', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData.mockRejectedValue(httpError(413));
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('big');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(store.saveStatus()).toBe('too-large');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks status "invalid" on 400 and does NOT retry', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData.mockRejectedValue(httpError(400));
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('bad');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(store.saveStatus()).toBe('invalid');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors Retry-After on 429 (uses it as the next delay)', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '2' }))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('x');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
// First attempt failed with 429 — we're now in the Retry-After window.
|
||||
expect(store.saveStatus()).toBe('rate-limited');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance 1.9s → still waiting (Retry-After was 2s).
|
||||
await vi.advanceTimersByTimeAsync(1900);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The full 2s → retry fires and succeeds.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(store.saveStatus()).toBe('saved');
|
||||
});
|
||||
|
||||
it('re-registers and retries on 401 mid-save', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(401))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('x');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(api.register).toHaveBeenCalledTimes(1);
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(store.saveStatus()).toBe('saved');
|
||||
});
|
||||
|
||||
// ── switchToken ───────────────────────────────────────────────────────────
|
||||
|
||||
it('switchToken cancels pending writes and does not flush old tree to new account', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
// Mutate, then switch BEFORE the debounce fires.
|
||||
store.addPage('old-account');
|
||||
const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
store.switchToken(newToken);
|
||||
|
||||
// Run all timers — the OLD debounce must have been cancelled,
|
||||
// so no PUT should have happened.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(api.putData).not.toHaveBeenCalled();
|
||||
expect(store.token()).toBe(newToken);
|
||||
});
|
||||
|
||||
it('switchToken rejects a non-UUIDv4 input', () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
store.switchToken('not-a-uuid');
|
||||
expect(store.token()).toBe(''); // never initialized
|
||||
});
|
||||
|
||||
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
||||
|
||||
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
expect(store.pages()).toHaveLength(0);
|
||||
|
||||
const otherTabTree = { pages: [mkPage('from-other-tab')] };
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: CACHE_KEY,
|
||||
newValue: JSON.stringify(otherTabTree),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('from-other-tab');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { Node, NodeState } from './node';
|
||||
|
||||
export interface InnerNodeState extends NodeState {
|
||||
dummy: any;
|
||||
}
|
||||
|
||||
export class InnerNode extends Node implements InnerNodeState {
|
||||
readonly dummy = 3;
|
||||
parent: Node;
|
||||
private nextVersion: this = null;
|
||||
readonly children: Array<InnerNode>;
|
||||
|
||||
protected constructor(children: Array<InnerNode> = [], id?: string) {
|
||||
super(children, id);
|
||||
this.children = children;
|
||||
}
|
||||
|
||||
get latestVersion(): this {
|
||||
let version;
|
||||
for (version = this; version.nextVersion !== null; version = version.nextVersion) {
|
||||
// pass
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
addChildren(children: Array<InnerNode>): this {
|
||||
return super.addChildren.call(this.latestVersion, children);
|
||||
}
|
||||
|
||||
replaceChild(update: { oldValue: InnerNode; newValue: InnerNode }): this {
|
||||
return super.replaceChild.call(this.latestVersion, update);
|
||||
}
|
||||
|
||||
changeKeys<T extends NodeState>(props: Partial<T>): this {
|
||||
if (this.nextVersion !== null) {
|
||||
this.latestVersion.changeKeys(props);
|
||||
}
|
||||
|
||||
let shouldClone = false;
|
||||
|
||||
for (const prop in props) {
|
||||
// @ts-ignore
|
||||
if (props.hasOwnProperty(prop) && props[prop] !== this[prop]) {
|
||||
shouldClone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!shouldClone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = this.cloneWithChangedKeys(props);
|
||||
|
||||
clone.children.forEach(c => (c.parent = clone));
|
||||
|
||||
this.nextVersion = clone;
|
||||
|
||||
this.parent.replaceChild({
|
||||
oldValue: this,
|
||||
newValue: clone
|
||||
});
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
protected onAfterClone() {}
|
||||
|
||||
protected cloneWithChangedKeys<T extends NodeState>(props: Partial<T>): this {
|
||||
const insides = Object.getOwnPropertyDescriptors(this);
|
||||
|
||||
for (const key in props) {
|
||||
if (props.hasOwnProperty(key)) {
|
||||
if (insides.hasOwnProperty(key)) {
|
||||
insides[key].value = props[key];
|
||||
} else {
|
||||
// @ts-ignore
|
||||
insides[key] = {
|
||||
value: props[key]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clone = Object.create(Object.getPrototypeOf(this), insides);
|
||||
clone.setUniqueness();
|
||||
clone.onAfterClone();
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { Unique } from './unique';
|
||||
import { InnerNode } from './inner-node';
|
||||
|
||||
export interface NodeState {
|
||||
children: Array<InnerNode>;
|
||||
}
|
||||
|
||||
export abstract class Node extends Unique implements NodeState {
|
||||
abstract readonly children: Array<InnerNode>;
|
||||
|
||||
protected constructor(children: Array<InnerNode> = [], id?: string) {
|
||||
super(id);
|
||||
children.forEach(c => (c.parent = this));
|
||||
}
|
||||
protected abstract changeKeys<T extends NodeState>(props: Partial<T>): this;
|
||||
|
||||
addChildren(children: Array<InnerNode>): this {
|
||||
return this.changeKeys<NodeState>({
|
||||
children: [...this.children, ...children]
|
||||
});
|
||||
}
|
||||
|
||||
replaceChild({ oldValue, newValue }: { oldValue: InnerNode; newValue: InnerNode }): this {
|
||||
if (oldValue === newValue) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return this.changeKeys<NodeState>({
|
||||
children: this.children.map(c => (c === oldValue ? newValue : c))
|
||||
});
|
||||
}
|
||||
|
||||
protected _log(indent = ''): string {
|
||||
const basicInfo = `${indent} - ${this.constructor.name}, #${this.id}`;
|
||||
let response = `${basicInfo}${' '.repeat(70 - basicInfo.length)}copies: ${this.copies}\n`;
|
||||
for (const c of this.children) {
|
||||
response += `${c._log(indent + ' ')}`;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public log() {
|
||||
// console.log(this._log());
|
||||
// console.log(`All in all, there are ${Unique.ObjectCount} objects.`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Node, NodeState } from './node';
|
||||
import { InnerNode } from './inner-node';
|
||||
|
||||
export class Root<T extends InnerNode> extends Node {
|
||||
readonly children$: Observable<Array<T>>;
|
||||
private readonly _children: BehaviorSubject<Array<T>>;
|
||||
|
||||
constructor(children: Array<T> = []) {
|
||||
super(children);
|
||||
this._children = new BehaviorSubject(children);
|
||||
this.children$ = this._children.asObservable();
|
||||
}
|
||||
|
||||
get children(): Array<T> {
|
||||
return this._children.getValue();
|
||||
}
|
||||
|
||||
set children(value: Array<T>) {
|
||||
this._children.next(value);
|
||||
}
|
||||
|
||||
changeKeys<U extends NodeState>(props: Partial<U>): this {
|
||||
if (props.hasOwnProperty('children')) {
|
||||
// @ts-ignore
|
||||
this.children = props.children;
|
||||
for (const child of this.children) {
|
||||
child.parent = this;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import * as uuid from 'uuid';
|
||||
import { ISerializable } from '../interfaces/serializable';
|
||||
import { IUnique } from '../interfaces/persistance/unique';
|
||||
|
||||
export class Unique implements ISerializable, IUnique {
|
||||
private static count = 0;
|
||||
|
||||
constructor(id?: string) {
|
||||
if (id) {
|
||||
this._id = id;
|
||||
// console.log('got id ' + id);
|
||||
} else {
|
||||
this.setUniqueness();
|
||||
// console.log('unique ' + this.id);
|
||||
}
|
||||
}
|
||||
|
||||
static get ObjectCount(): number {
|
||||
return Unique.count;
|
||||
}
|
||||
|
||||
private _id: string;
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
private _copies = 0;
|
||||
|
||||
get copies(): number {
|
||||
return this._copies;
|
||||
}
|
||||
|
||||
protected setUniqueness() {
|
||||
this._id = uuid.v4();
|
||||
Unique.count++;
|
||||
this._copies++;
|
||||
}
|
||||
|
||||
serialize(referenceSerializer: (ref: object) => any): object {
|
||||
return {
|
||||
id: this.id
|
||||
};
|
||||
}
|
||||
}
|
||||
106
frontend/src/app/styles/_form-shared.scss
Normal file
106
frontend/src/app/styles/_form-shared.scss
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.95rem;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
&--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-ui);
|
||||
transition: background 0.15s;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: transparent;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #e53e3e;
|
||||
margin-right: auto;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(229, 62, 62, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,32 @@
|
|||
import { IColor } from '../interfaces/color';
|
||||
import { HslColor } from '../models';
|
||||
import { hash } from './hash';
|
||||
|
||||
export const lighten = (by: number, { h, s, l }: IColor): IColor => {
|
||||
let newL = l + by;
|
||||
if (newL > 100) {
|
||||
newL = 100;
|
||||
} else if (newL < 0) {
|
||||
newL = 0;
|
||||
}
|
||||
/**
|
||||
* Lighten (or darken) an HslColor by a number of percentage points.
|
||||
* `byPercentPoints` is in raw percent (e.g. 25 means +25 lightness points out of 100).
|
||||
* Clamps the result to [0, 1].
|
||||
*/
|
||||
export function lighten(byPercentPoints: number, c: HslColor): HslColor {
|
||||
let newL = c.l * 100 + byPercentPoints;
|
||||
if (newL > 100) newL = 100;
|
||||
else if (newL < 0) newL = 0;
|
||||
return { h: c.h, s: c.s, l: newL / 100 };
|
||||
}
|
||||
|
||||
return { h, s, l: newL };
|
||||
};
|
||||
/**
|
||||
* Converts an HslColor (all values 0–1) to a CSS hsl() string.
|
||||
* Note: the new app stores h/s/l normalised to [0, 1].
|
||||
*/
|
||||
export function toCss(c: HslColor): string {
|
||||
return `hsl(${c.h * 360}, ${c.s * 100}%, ${c.l * 100}%)`;
|
||||
}
|
||||
|
||||
export const toHslString = ({ h, s, l }: IColor): string => {
|
||||
return `hsl(${h}, ${s}%, ${l}%)`;
|
||||
};
|
||||
/**
|
||||
* Derive a per-tag color by offsetting the tower's base lightness deterministically.
|
||||
* Uses FNV-1a hash → offset in [−25, +25) lightness percentage points.
|
||||
* All blocks in the same tower vary in lightness only, preserving the hue and saturation.
|
||||
*/
|
||||
export function getColorOfTag(tag: string, base: HslColor): string {
|
||||
const offset = (hash(tag) - 0.5) * 50; // → [−25, +25) percentage points
|
||||
return toCss(lighten(offset, base));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
export const hash = (text: string): number => {
|
||||
// Return number from [0, 1)
|
||||
if (!text) {
|
||||
return 0;
|
||||
/**
|
||||
* Deterministic hash of a string returning a value in [0, 1).
|
||||
*
|
||||
* Ports the legacy hash.ts exactly:
|
||||
* h = ((h << 5) - h + charCode) | 0, seed = 7
|
||||
* result = h / (2^32 - 2) + 0.5
|
||||
*
|
||||
* The legacy formula keeps the same distribution as the original Angular 7
|
||||
* reference so per-tag block colours are stable across the port.
|
||||
*/
|
||||
export function hash(s: string): number {
|
||||
if (!s) return 0;
|
||||
let h = 7;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
// Same bit-ops as legacy: (h << 5) - h == h * 31, truncated to int32
|
||||
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
const hashValue = Array.prototype.reduce.call(
|
||||
text, // tslint:disable-next-line:no-bitwise
|
||||
(value, char) => ((value << 5) - value + (char.charCodeAt(0) as number)) | 0,
|
||||
7
|
||||
);
|
||||
return hashValue / (Math.pow(2, 32) - 2) + 0.5;
|
||||
};
|
||||
// Map the signed int32 to [0, 1) — same formula as legacy
|
||||
return h / (Math.pow(2, 32) - 2) + 0.5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export const range = ({ min = 0, max = Infinity, step = 1 }: { min?: number; max?: number; step?: number }) => {
|
||||
return {
|
||||
*[Symbol.iterator]() {
|
||||
for (let i = min; i < max; yield i, i += step) {}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const top = <T>(iterable: ArrayLike<T>): T => {
|
||||
return iterable.length > 0 ? iterable[iterable.length - 1] : null;
|
||||
};
|
||||
BIN
frontend/src/assets/fonts/material-icons.woff2
Normal file
BIN
frontend/src/assets/fonts/material-icons.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/open-sans-condensed-300.woff2
Normal file
BIN
frontend/src/assets/fonts/open-sans-condensed-300.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/raleway-400.woff2
Normal file
BIN
frontend/src/assets/fonts/raleway-400.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/roboto-300.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto-300.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue