From e58e589f8e3104767f92641d8150a71e1a3ce591 Mon Sep 17 00:00:00 2001 From: Linus Miller Date: Tue, 3 Feb 2026 23:51:46 +0100 Subject: [PATCH] WIP: auth public --- client/admin/components/app.tsx | 4 +- client/admin/components/current_user.tsx | 2 +- client/admin/components/login_form.tsx | 2 +- client/admin/components/route.tsx | 2 +- client/public/client.ts | 4 +- client/public/components/account_page.tsx | 90 +++++ client/public/components/accounts_page.tsx | 4 +- client/public/components/app.tsx | 48 +-- .../components/current_user.module.scss | 5 + client/public/components/current_user.tsx | 38 ++ client/public/components/header.module.scss | 18 + client/public/components/header.tsx | 4 +- client/public/components/input.module.scss | 69 ++++ client/public/components/input.tsx | 5 + .../public/components/login_form.module.scss | 30 ++ client/public/components/login_form.tsx | 77 ++++ client/public/components/login_page.tsx | 10 + client/public/components/route.tsx | 34 ++ .../public/components/transactions_page.tsx | 4 +- client/public/routes.ts | 29 ++ client/public/styles/main.scss | 11 + client/shared/contexts/auth.tsx | 15 + .../contexts/current_user.ts | 0 package.json | 2 + pnpm-lock.yaml | 330 ++++++++++++++++++ server/config/auth.ts | 6 +- server/plugins/auth/routes/login.ts | 7 + server/plugins/auth/routes/logout.ts | 2 +- server/routes/api/accounts.ts | 83 +++++ server/server.ts | 22 +- server/templates/public.ts | 53 +-- shared/global.d.ts | 1 + 32 files changed, 942 insertions(+), 69 deletions(-) create mode 100644 client/public/components/account_page.tsx create mode 100644 client/public/components/current_user.module.scss create mode 100644 client/public/components/current_user.tsx create mode 100644 client/public/components/input.module.scss create mode 100644 client/public/components/input.tsx create mode 100644 client/public/components/login_form.module.scss create mode 100644 client/public/components/login_form.tsx create mode 100644 client/public/components/login_page.tsx create mode 100644 client/public/components/route.tsx create mode 100644 client/shared/contexts/auth.tsx rename client/{admin => shared}/contexts/current_user.ts (100%) diff --git a/client/admin/components/app.tsx b/client/admin/components/app.tsx index bda92ef..cc96be3 100644 --- a/client/admin/components/app.tsx +++ b/client/admin/components/app.tsx @@ -2,7 +2,7 @@ import { h, type FunctionComponent } from 'preact' import { useState } from 'preact/hooks' import { Router } from 'preact-router' -import CurrentUserContext from '../contexts/current_user.ts' +import CurrentUserContext from '../../shared/contexts/current_user.ts' import { NotificationsProvider } from '../contexts/notifications.tsx' import routes from '../routes.ts' @@ -22,7 +22,7 @@ const App: FunctionComponent<{ state: ANY }> = ({ state }) => {
- Carson Admin + BRF Admin
diff --git a/client/admin/components/current_user.tsx b/client/admin/components/current_user.tsx index c3b6a36..1f42b23 100644 --- a/client/admin/components/current_user.tsx +++ b/client/admin/components/current_user.tsx @@ -1,7 +1,7 @@ import { h, type FunctionComponent } from 'preact' import cn from 'classnames' -import { useCurrentUser } from '../contexts/current_user.ts' +import { useCurrentUser } from '../../shared/contexts/current_user.ts' import s from './current_user.module.scss' const CurrentUser: FunctionComponent<{ className?: string }> = ({ className }) => { diff --git a/client/admin/components/login_form.tsx b/client/admin/components/login_form.tsx index 79a0817..1353cd8 100644 --- a/client/admin/components/login_form.tsx +++ b/client/admin/components/login_form.tsx @@ -1,7 +1,7 @@ import { h, type FunctionComponent, type TargetedSubmitEvent } from 'preact' import type { FetchError } from 'rek' import { useCallback } from 'preact/hooks' -import { useCurrentUser } from '../contexts/current_user.ts' +import { useCurrentUser } from '../../shared/contexts/current_user.ts' import rek from 'rek' import useRequestState from '../../shared/hooks/use_request_state.ts' diff --git a/client/admin/components/route.tsx b/client/admin/components/route.tsx index 0c0447a..3e767f3 100644 --- a/client/admin/components/route.tsx +++ b/client/admin/components/route.tsx @@ -2,7 +2,7 @@ import { h } from 'preact' import { useEffect, useState } from 'preact/hooks' import { route } from 'preact-router' -import { useCurrentUser } from '../contexts/current_user.ts' +import { useCurrentUser } from '../../shared/contexts/current_user.ts' /** @type {import('preact').FunctionComponent<{ auth: boolean, path: string, component: () => any, loadComponent: boolean}} Page */ const Route = ({ auth, path, component, loadComponent = true }) => { diff --git a/client/public/client.ts b/client/public/client.ts index 48fe099..5bb7964 100644 --- a/client/public/client.ts +++ b/client/public/client.ts @@ -3,6 +3,6 @@ import './styles/main.scss' import { h, hydrate } from 'preact' import App from './components/app.tsx' -const STATE = typeof __STATE__ !== 'undefined' ? __STATE__ : undefined +const state = typeof __STATE__ === 'undefined' ? { user: null } : __STATE__ -hydrate(h(App, STATE), document.body) +hydrate(h(App, { state }), document.body) diff --git a/client/public/components/account_page.tsx b/client/public/components/account_page.tsx new file mode 100644 index 0000000..ea777ae --- /dev/null +++ b/client/public/components/account_page.tsx @@ -0,0 +1,90 @@ +import { h, type FunctionComponent } from 'preact' +import { useEffect, useRef } from 'preact/hooks' +import * as d3 from 'd3' +import rek from 'rek' +import Head from './head.ts' +import usePromise from '../../shared/hooks/use_promise.ts' + +type AccountPageProps = { + number: number +} + +function empty(element: HTMLElement) { + while (element.lastElementChild) { + element.removeChild(element.lastElementChild) + } +} + +const AccountPage: FunctionComponent = ({ number }) => { + const graphRef = useRef(null) + const sums = usePromise<{ month: string; totalAmount: number }[]>(() => rek(`/api/accounts/month-sum/${number}`)) + + useEffect(() => { + empty(graphRef.current!) + + const width = 1000 + const height = 1000 + const margin = { left: 50, bottom: 40 } + const stageWidth = 1000 - margin.left + const stageHeight = 1000 - margin.bottom + + const svg = d3 + .select(graphRef.current) + .append('svg') + .attr('width', width) + .attr('height', height) + .append('g') + .attr('transform', `translate(${margin.left}, 0)`) + + const x = d3.scaleBand( + sums.map(({ month }) => month), + [0, stageWidth], + ) + + svg.append('g').attr('transform', `translate(0, ${stageHeight})`).call(d3.axisBottom(x)) + + const y = d3.scaleLinear([0, d3.max(sums, (d) => Math.abs(d.totalAmount))], [stageHeight, 0]) + + svg.append('g').call(d3.axisLeft(y)) + svg + .append('path') + .datum(sums) + .attr('fill', 'none') + .attr('stroke', 'steelblue') + .attr('stroke-width', 1.5) + .attr( + 'd', + d3 + .line() + .x((d) => x(d.month)) + .y((d) => y(Math.abs(d.totalAmount))), + ) + }, []) + + return ( +
+ + : Konto + + +

Konto {number}

+
+ + + + + + + {sums.map((sum) => ( + + + + + ))} + +
MånadSumma
{sum.month}{sum.totalAmount}
+
+ ) +} + +export default AccountPage diff --git a/client/public/components/accounts_page.tsx b/client/public/components/accounts_page.tsx index b73105f..aeb31c8 100644 --- a/client/public/components/accounts_page.tsx +++ b/client/public/components/accounts_page.tsx @@ -23,7 +23,9 @@ const AccountsPage: FunctionComponent = () => { {accounts.map((account) => ( - {account.number} + + {account.number} + {account.description} ))} diff --git a/client/public/components/app.tsx b/client/public/components/app.tsx index 01b3f33..5dfc285 100644 --- a/client/public/components/app.tsx +++ b/client/public/components/app.tsx @@ -1,19 +1,22 @@ import { h, type FunctionComponent } from 'preact' import { useCallback, useEffect, useRef } from 'preact/hooks' -import { LocationProvider, Route, Router } from 'preact-iso' +import { LocationProvider, Router } from 'preact-iso' import { get } from 'lowline' +import { AuthProvider } from '../../shared/contexts/auth.tsx' +import throttle from '../../shared/utils/throttle.ts' import Head from './head.ts' import Footer from './footer.tsx' import Header from './header.tsx' import ErrorPage from './error_page.tsx' +import { AuthRoute, Refresh } from './route.tsx' import routes from '../routes.ts' -import throttle from '../../shared/utils/throttle.ts' import s from './app.module.scss' type Props = { error?: Error title?: string + state: ANY } const scroll = () => { @@ -22,7 +25,7 @@ const scroll = () => { window.scrollTo(0, offset || 0) } -const App: FunctionComponent = ({ error, title }) => { +const App: FunctionComponent = ({ error, title, state }) => { const loadRef = useRef(false) useEffect(() => { @@ -61,27 +64,30 @@ const App: FunctionComponent = ({ error, title }) => { return ( -
- - {title || 'Untitled'} - + +
+ + {title || 'BRF'} + -
+
-
- {error ? ( - - ) : ( - - {routes.map((route) => ( - - ))} - - )} -
+
+ {error ? ( + + ) : ( + + {routes.map((route) => ( + + ))} + + + )} +
-
-
+
+
+
) } diff --git a/client/public/components/current_user.module.scss b/client/public/components/current_user.module.scss new file mode 100644 index 0000000..2f6bc74 --- /dev/null +++ b/client/public/components/current_user.module.scss @@ -0,0 +1,5 @@ +.links { + display: flex; + gap: 10px; + justify-content: end; +} diff --git a/client/public/components/current_user.tsx b/client/public/components/current_user.tsx new file mode 100644 index 0000000..b6677bc --- /dev/null +++ b/client/public/components/current_user.tsx @@ -0,0 +1,38 @@ +import { h, type FunctionComponent } from 'preact' +import { Show } from '@preact/signals/utils' +import { useComputed } from '@preact/signals' +import cn from 'classnames' + +import { useAuth } from '../../shared/contexts/auth.tsx' +import s from './current_user.module.scss' + +const CurrentUser: FunctionComponent<{ className?: string }> = ({ className }) => { + const { user } = useAuth() + const noUser = useComputed(() => !user.value) + + return ( +
+ +
{user.value?.email}
+ + +
+ +
+

You are not logged in

+ +
+ Login + Register +
+
+
+
+ ) +} + +export default CurrentUser diff --git a/client/public/components/header.module.scss b/client/public/components/header.module.scss index b0d10a4..503d4cd 100644 --- a/client/public/components/header.module.scss +++ b/client/public/components/header.module.scss @@ -1,6 +1,14 @@ @use '../../shared/styles/utils'; +.base { + display: grid; + grid-template-columns: 1fr max-content; + grid-template-rows: auto auto; +} + .nav { + grid-area: 2 / 1 / 3 / 2; + > ul { @include utils.wipe-list(); @@ -11,6 +19,16 @@ display: block; padding: 5px; } + + &:first-child { + > a { + padding-left: 0; + } + } } } } + +.currentUser { + grid-area: 1 / 2 / 3 / 3; +} diff --git a/client/public/components/header.tsx b/client/public/components/header.tsx index 2be5552..722d3dc 100644 --- a/client/public/components/header.tsx +++ b/client/public/components/header.tsx @@ -1,10 +1,11 @@ import { h, type FunctionComponent } from 'preact' +import CurrentUser from './current_user.tsx' import s from './header.module.scss' import type { Route } from '../../../shared/types.ts' const Header: FunctionComponent<{ routes: Route[] }> = ({ routes }) => ( -
+

BRF Tegeltrasten

+
) diff --git a/client/public/components/input.module.scss b/client/public/components/input.module.scss new file mode 100644 index 0000000..d052ecd --- /dev/null +++ b/client/public/components/input.module.scss @@ -0,0 +1,69 @@ +@use 'sass:color'; + +.base { + display: flex; + flex-direction: column; + + &:not(:last-child):not(.noMargin) { + margin-bottom: var(--gutter); + } +} + +.label { + order: 1; + font-weight: bold; + margin-bottom: var(--form-label-margin); + + &:has(input:invalid) { + color: var(--form-invalid-color); + } + + &:has(input:valid) { + color: var(--form-valid-color); + } +} + +.element { + order: 2; + width: 100%; + box-shadow: none; + border: 1px solid var(--form-border-color); + padding: var(--form-element-padding); + font-family: var(--body-font-family); + background: white; + + &:focus { + outline: none; + border-color: var(--color-blue); + // box-shadow: 0 0 5px 0px color.scale(var(--color-blue), $alpha: -20%); + } + + .touched &:invalid { + border-color: var(--form-invalid-color); + + ~ .label { + color: var(--form-invalid-color); + } + + &:focus { + // box-shadow: 0 0 5px 0px color.scale(var(--form-invalid-color), $alpha: -20%); + } + } + + .touched &:valid { + border-color: var(--form-valid-color); + + ~ .label { + color: var(--form-valid-color); + } + + &:focus { + // box-shadow: 0 0 5px 0px color.scale(var(--form-valid-color), $alpha: -20%); + } + } +} + +.errorLabel { + color: var(--form-invalid-color); + margin-top: var(--form-label-margin); +} diff --git a/client/public/components/input.tsx b/client/public/components/input.tsx new file mode 100644 index 0000000..c5b631b --- /dev/null +++ b/client/public/components/input.tsx @@ -0,0 +1,5 @@ +import inputFactory from '../../shared/components/input_factory.tsx' + +import s from './input.module.scss' + +export default inputFactory(s) diff --git a/client/public/components/login_form.module.scss b/client/public/components/login_form.module.scss new file mode 100644 index 0000000..3323918 --- /dev/null +++ b/client/public/components/login_form.module.scss @@ -0,0 +1,30 @@ +.root { + max-width: 400px; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; +} + +.error, +.input { + grid-column: 1 / 3; +} + +.footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.button { + grid-column: 2 / 3; +} + +.links { + grid-column: 1 / 3; + justify-self: center; + + li:not(:last-child) { + padding-bottom: 4px; + } +} diff --git a/client/public/components/login_form.tsx b/client/public/components/login_form.tsx new file mode 100644 index 0000000..9662182 --- /dev/null +++ b/client/public/components/login_form.tsx @@ -0,0 +1,77 @@ +import { h, type FunctionComponent, type TargetedSubmitEvent } from 'preact' +import type { FetchError } from 'rek' +import { useCallback } from 'preact/hooks' +import { useAuth } from '../../shared/contexts/auth.tsx' +import rek from 'rek' + +import useRequestState from '../../shared/hooks/use_request_state.ts' +import Input from './input.tsx' +import s from './login_form.module.scss' + +const LoginForm: FunctionComponent = () => { + const [{ error, pending }, actions] = useRequestState() + const { user } = useAuth() + + const login = useCallback(async (e: TargetedSubmitEvent) => { + e.preventDefault() + + actions.pending() + + const form = e.currentTarget + + try { + const response = await rek.post( + '/auth/login', + { + email: form.email.value, + password: form.password.value, + }, + { response: false }, + ) + + const lastVisit = response.headers.get('x-last-visit') + + if (lastVisit) { + localStorage.setItem('lastVisit', lastVisit) + } + + const result = await response.json() + + user.value = result + } catch (err) { + actions.error(err as FetchError) + } + }, []) + + return ( +
+ {error &&
Error: {error.body?.message || error.message}
} + + + + + + + +
+ ) +} + +export default LoginForm diff --git a/client/public/components/login_page.tsx b/client/public/components/login_page.tsx new file mode 100644 index 0000000..3a6e836 --- /dev/null +++ b/client/public/components/login_page.tsx @@ -0,0 +1,10 @@ +import { h } from 'preact' +import LoginForm from './login_form.tsx' + +const LoginPage = () => ( +
+ +
+) + +export default LoginPage diff --git a/client/public/components/route.tsx b/client/public/components/route.tsx new file mode 100644 index 0000000..aa39ffe --- /dev/null +++ b/client/public/components/route.tsx @@ -0,0 +1,34 @@ +import { h, type FunctionComponent } from 'preact' +import { useLocation, type RouteProps } from 'preact-iso' +import { useAuth } from '../../shared/contexts/auth.tsx' + +type AuthRouteProps = { + auth?: boolean + user?: any +} & RouteProps + +export const Refresh: FunctionComponent<{ path: string }> = () => { + location.replace(location.href) +} + +export const Redirect: FunctionComponent<{ to: string; replace: boolean }> = ({ to, replace = false }) => { + const { route } = useLocation() + + route(to, replace) +} + +export const AuthRoute: FunctionComponent = ({ auth, component, ...props }) => { + const { user } = useAuth() + + if (auth === true && !user.value) { + return + } else if (auth === false && user.value) { + const url = localStorage.getItem('lastVisit') || '/' + + localStorage.removeItem('lastVisit') + + return + } else { + return h(component, props) + } +} diff --git a/client/public/components/transactions_page.tsx b/client/public/components/transactions_page.tsx index 9ab8ee3..e51a8dd 100644 --- a/client/public/components/transactions_page.tsx +++ b/client/public/components/transactions_page.tsx @@ -71,7 +71,9 @@ const TransactionsPage: FunctionComponent = () => { {transactions.map((transaction) => ( {(transaction.transactionDate as unknown as string)?.slice(0, 10)} - {transaction.accountNumber} + + {transaction.accountNumber} + {(transaction.amount as unknown as number) >= 0 ? formatNumber(transaction.amount as unknown as number) diff --git a/client/public/routes.ts b/client/public/routes.ts index e0f27a5..a286d1a 100644 --- a/client/public/routes.ts +++ b/client/public/routes.ts @@ -1,3 +1,4 @@ +import Account from './components/account_page.tsx' import Accounts from './components/accounts_page.tsx' import Balances from './components/balances_page.tsx' import Entries from './components/entries_page.tsx' @@ -5,6 +6,7 @@ import Entry from './components/entry_page.tsx' import Invoice from './components/invoice_page.tsx' import Invoices from './components/invoices_page.tsx' import InvoicesBySupplier from './components/invoices_by_supplier_page.tsx' +import Login from './components/login_page.tsx' import Objects from './components/objects_page.tsx' import Results from './components/results_page.tsx' import Start from './components/start_page.tsx' @@ -16,18 +18,29 @@ export default [ name: 'start', title: 'Start', component: Start, + auth: true, }, { path: '/accounts', name: 'accounts', title: 'Konton', component: Accounts, + auth: true, + }, + { + path: '/accounts/:number', + name: 'account', + title: 'Konto', + component: Account, + nav: false, + auth: true, }, { path: '/entries', name: 'entries', title: 'Verifikat', component: Entries, + auth: true, }, { path: '/entries/:id', @@ -35,18 +48,21 @@ export default [ title: 'Verifikat :id', component: Entry, nav: false, + auth: true, }, { path: '/objects', name: 'objects', title: 'Objekt', component: Objects, + auth: true, }, { path: 'invoices', name: 'invoices', title: 'Fakturor', component: Invoices, + auth: true, }, { path: '/invoices/:id', @@ -54,6 +70,7 @@ export default [ title: 'Faktura :id', component: Invoice, nav: false, + auth: true, }, { path: '/invoices/by-supplier/:supplier', @@ -61,23 +78,35 @@ export default [ title: 'Fakturor från leverantör', component: InvoicesBySupplier, nav: false, + auth: true, }, { path: '/balances', name: 'balances', title: 'Balanser', component: Balances, + auth: true, }, { path: '/results', name: 'results', title: 'Resultat', component: Results, + auth: true, }, { path: '/transactions', name: 'transactions', title: 'Transaktioner', component: Transactions, + auth: true, + }, + { + path: '/login', + name: 'login', + title: 'Logga in', + component: Login, + nav: false, + auth: false, }, ] diff --git a/client/public/styles/main.scss b/client/public/styles/main.scss index 4fbe872..1f21eae 100644 --- a/client/public/styles/main.scss +++ b/client/public/styles/main.scss @@ -1,3 +1,14 @@ +:root { + --color-green: green; + --color-red: red; + --gutter: 16px; + --form-valid-color: var(--color-green); + --form-invalid-color: var(--color-red); + --form-border-color: #d2d6de; + --form-element-padding: 9px; + --form-label-margin: 6px; +} + *, *:before, *:after { diff --git a/client/shared/contexts/auth.tsx b/client/shared/contexts/auth.tsx new file mode 100644 index 0000000..67de30c --- /dev/null +++ b/client/shared/contexts/auth.tsx @@ -0,0 +1,15 @@ +import { h, createContext, type FunctionComponent } from 'preact' +import { useSignal } from '@preact/signals' +import { useContext } from 'preact/hooks' + +type AuthContextType = { user: ANY } + +const AuthContext = createContext(null) + +export const AuthProvider: FunctionComponent<{ user: ANY }> = ({ children, user }) => { + const userSignal = useSignal(user) + + return {children} +} + +export const useAuth = () => useContext(AuthContext) as AuthContextType diff --git a/client/admin/contexts/current_user.ts b/client/shared/contexts/current_user.ts similarity index 100% rename from client/admin/contexts/current_user.ts rename to client/shared/contexts/current_user.ts diff --git a/package.json b/package.json index 09a8406..1eb37ab 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,10 @@ "@fastify/session": "^11.1.1", "@fastify/static": "^9.0.0", "@fastify/type-provider-typebox": "^6.1.0", + "@preact/signals": "^2.6.2", "chalk": "^5.6.2", "classnames": "^2.5.1", + "d3": "^7.9.0", "easy-tz": "^0.2.0", "fastify": "^5.7.2", "fastify-plugin": "^5.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cb3e1b..7012aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,18 @@ importers: '@fastify/type-provider-typebox': specifier: ^6.1.0 version: 6.1.0(typebox@1.0.80) + '@preact/signals': + specifier: ^2.6.2 + version: 2.6.2(preact@10.28.2) chalk: specifier: ^5.6.2 version: 5.6.2 classnames: specifier: ^2.5.1 version: 2.5.1 + d3: + specifier: ^7.9.0 + version: 7.9.0 easy-tz: specifier: ^0.2.0 version: 0.2.0 @@ -554,21 +560,25 @@ packages: resolution: {integrity: sha512-j4QzfCM8ks+OyM+KKYWDiBEQsm5RCW50H1Wz16wUyoFsobJ+X5qqcJxq6HvkE07m8euYmZelyB0WqsiDoz1v8g==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/linux-arm64-musl@1.42.0': resolution: {integrity: sha512-g5b1Uw7zo6yw4Ymzyd1etKzAY7xAaGA3scwB8tAp3QzuY7CYdfTwlhiLKSAKbd7T/JBgxOXAGNcLDorJyVTXcg==} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/linux-x64-gnu@1.42.0': resolution: {integrity: sha512-HnD99GD9qAbpV4q9iQil7mXZUJFpoBdDavfcC2CgGLPlawfcV5COzQPNwOgvPVkr7C0cBx6uNCq3S6r9IIiEIg==} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/linux-x64-musl@1.42.0': resolution: {integrity: sha512-8NTe8A78HHFn+nBi+8qMwIjgv9oIBh+9zqCPNLH56ah4vKOPvbePLI6NIv9qSkmzrBuu8SB+FJ2TH/G05UzbNA==} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/win32-arm64@1.42.0': resolution: {integrity: sha512-lAPS2YAuu+qFqoTNPFcNsxXjwSV0M+dOgAzzVTAN7Yo2ifj+oLOx0GsntWoM78PvQWI7Q827ZxqtU2ImBmDapA==} @@ -609,36 +619,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -671,6 +687,14 @@ packages: '@babel/core': 7.x vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x + '@preact/signals-core@1.12.2': + resolution: {integrity: sha512-5Yf8h1Ke3SMHr15xl630KtwPTW4sYDFkkxS0vQ8UiQLWwZQnrF9IKaVG1mN5VcJz52EcWs2acsc/Npjha/7ysA==} + + '@preact/signals@2.6.2': + resolution: {integrity: sha512-80PMfNS3d8t/J3cRNEJP14zRioWnavgqzX/+tsNGiCX6rpD26+eLkUQpa1sIeOERZMink+dAi34y0MJhg11LKQ==} + peerDependencies: + preact: '>= 10.25.0 || >=11.0.0-0' + '@prefresh/babel-plugin@0.5.2': resolution: {integrity: sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==} @@ -735,66 +759,79 @@ packages: resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.0': resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.0': resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.0': resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.0': resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.0': resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.0': resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.0': resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.0': resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.0': resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.0': resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.0': resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.0': resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.0': resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} @@ -1078,11 +1115,133 @@ packages: resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} engines: {node: '>=20'} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-dsv@3.0.1: resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} engines: {node: '>=12'} hasBin: true + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + data-urls@6.0.1: resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} engines: {node: '>=20'} @@ -1111,6 +1270,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1365,6 +1527,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ioredis@5.9.2: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} @@ -1808,6 +1974,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.57.0: resolution: {integrity: sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2595,6 +2764,13 @@ snapshots: - rollup - supports-color + '@preact/signals-core@1.12.2': {} + + '@preact/signals@2.6.2(preact@10.28.2)': + dependencies: + '@preact/signals-core': 1.12.2 + preact: 10.28.2 + '@prefresh/babel-plugin@0.5.2': {} '@prefresh/core@1.5.9(preact@10.28.2)': @@ -2938,12 +3114,158 @@ snapshots: css-tree: 3.1.0 lru-cache: 11.2.5 + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-dsv@3.0.1: dependencies: commander: 7.2.0 iconv-lite: 0.6.3 rw: 1.3.3 + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + data-urls@6.0.1: dependencies: whatwg-mimetype: 5.0.0 @@ -2988,6 +3310,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + denque@2.1.0: {} depd@2.0.0: {} @@ -3270,6 +3596,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + ioredis@5.9.2: dependencies: '@ioredis/commands': 1.5.0 @@ -3711,6 +4039,8 @@ snapshots: rfdc@1.4.1: {} + robust-predicates@3.0.2: {} + rollup@4.57.0: dependencies: '@types/estree': 1.0.8 diff --git a/server/config/auth.ts b/server/config/auth.ts index e6755f1..bf2da39 100644 --- a/server/config/auth.ts +++ b/server/config/auth.ts @@ -26,9 +26,9 @@ const maxLoginAttempts = 5 const requireVerification = true const redirects = { - login: '/admin/', - logout: '/admin/login', - register: '/admin/login', + login: '/', + logout: '/login', + register: '/login', } const remember = { diff --git a/server/plugins/auth/routes/login.ts b/server/plugins/auth/routes/login.ts index 2975d4d..ae32fbc 100644 --- a/server/plugins/auth/routes/login.ts +++ b/server/plugins/auth/routes/login.ts @@ -74,6 +74,13 @@ const login: RouteHandler<{ Body: z.infer }> = async function .where('id', '=', user.id) .execute() + const lastVisit = request.session.get('lastVisit') + + if (lastVisit) { + reply.header('x-last-visit', lastVisit) + request.session.set('lastVisit', undefined) + } + return reply.status(200).send(_.omit(user, 'password')) } catch (err) { this.log.error(err) diff --git a/server/plugins/auth/routes/logout.ts b/server/plugins/auth/routes/logout.ts index 998daa7..b8438db 100644 --- a/server/plugins/auth/routes/logout.ts +++ b/server/plugins/auth/routes/logout.ts @@ -9,7 +9,7 @@ const schema: FastifySchema = { const logout: RouteHandler = async function (request, reply) { await request.logout() - return reply.redirect('/admin/login') + return reply.redirect('/login') } export default { diff --git a/server/routes/api/accounts.ts b/server/routes/api/accounts.ts index 10cd045..6479523 100644 --- a/server/routes/api/accounts.ts +++ b/server/routes/api/accounts.ts @@ -3,6 +3,7 @@ import * as z from 'zod' import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import { AccountSchema } from '../../schemas/db.ts' +import { sql } from 'kysely' const accountRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { const { db } = fastify @@ -20,6 +21,88 @@ const accountRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { }, }) + fastify.route({ + url: '/:number', + method: 'GET', + schema: { + params: z.object({ + number: z.coerce.number(), + }), + // response: { + // 200: z.array(AccountSchema), + // }, + }, + handler(request) { + return db + .selectFrom('account as a') + .innerJoin('transaction as t', 't.accountNumber', 'a.number') + .innerJoin('entry as e', 'e.id', 't.entryId') + .selectAll('a') + .select('e.transactionDate') + .where('a.number', '=', request.params.number) + .orderBy('e.transactionDate', 'asc') + .execute() + }, + }) + + fastify.route({ + url: '/month-sum/:number', + method: 'GET', + schema: { + params: z.object({ + number: z.coerce.number(), + }), + + response: { + 200: z.array( + z.object({ + month: z.string(), + totalAmount: z.coerce.number(), + }), + ), + }, + }, + handler(request) { + return db + .selectFrom('transaction as t') + .innerJoin('entry as e', 'e.id', 't.entryId') + .select([ + sql`to_char(date_trunc('month', e."transactionDate"), 'YYYY-MM')`.as('month'), + sql`sum(amount)`.as('totalAmount'), + ]) + .groupBy(sql`date_trunc('month', e."transactionDate")`) + .orderBy('month') + .where('t.accountNumber', '=', request.params.number) + .execute() + }, + }) + + fastify.route({ + url: '/year-sum/:number', + method: 'GET', + schema: { + params: z.object({ + number: z.coerce.number(), + }), + }, + handler(request) { + return ( + db + .selectFrom('transaction as t') + .innerJoin('entry as e', 'e.id', 't.entryId') + // .select([sql`date_trunc('year', e."transactionDate")`.as('year'), sql`sum(amount)`.as('totalAmount')]) + .select([ + sql`to_char(date_trunc('year', e."transactionDate"), 'YYYY')`.as('year'), + sql`sum(amount)`.as('totalAmount'), + ]) + .groupBy(sql`date_trunc('year', e."transactionDate")`) + .orderBy('year') + .where('t.accountNumber', '=', request.params.number) + .execute() + ) + }, + }) + done() } diff --git a/server/server.ts b/server/server.ts index c2334b9..f2d0ab7 100644 --- a/server/server.ts +++ b/server/server.ts @@ -48,6 +48,19 @@ export default async (options?: FastifyServerOptions) => { server.register(vitePlugin, { mode: env.NODE_ENV, createErrorHandler: ErrorHandler, + createPreHandler(route) { + return async function preHandler(request, reply) { + if (request.session.userId) { + return (reply.ctx = { user: await request.user }) + } else if (route.auth) { + if (request.url !== '/') { + request.session.set('lastVisit', request.url) + } + + return reply.redirect('/login') + } + } + }, entries: { public: { path: '/', @@ -56,15 +69,6 @@ export default async (options?: FastifyServerOptions) => { admin: { path: '/admin', template: templateAdmin, - createPreHandler(route) { - return async function preHandler(request, reply) { - if (request.session.userId) { - return (reply.ctx = { user: await request.user }) - } else if (route.auth) { - return reply.redirect('/admin/login') - } - } - }, }, }, }) diff --git a/server/templates/public.ts b/server/templates/public.ts index 2b4195e..5a47eb0 100644 --- a/server/templates/public.ts +++ b/server/templates/public.ts @@ -16,28 +16,31 @@ interface Options { } export default ({ content, css, head, preload, script, state }: Options) => html` - - - - - - - - ${css?.map((href) => ``)} - ${preload?.map((href) => ``)} - ${head?.then((head) => head.title)} - ${head?.then((head) => - head.tags?.map( - (tag) => - `<${tag.type} ${Object.entries(tag.attributes) - .map(([name, content]) => `${name}='${content}'`) - .join(' ')}/>`, - ), - )} - - - ${content} - ${state?.then((state) => ``)} - - -` + + + + + + + + ${css?.map((href) => ``)} + ${preload?.map((href) => ``)} + ${head?.then((head) => head.title)} + ${head?.then((head) => + head.tags?.map( + (tag) => + `<${tag.type} ${Object.entries(tag.attributes) + .map(([name, content]) => `${name}='${content}'`) + .join(' ')}/>`, + ), + )} + + + ${content} + ${state?.then((state) => ``)} + + + ` diff --git a/shared/global.d.ts b/shared/global.d.ts index e1a6389..6b7b20f 100644 --- a/shared/global.d.ts +++ b/shared/global.d.ts @@ -13,6 +13,7 @@ declare global { declare module 'fastify' { interface Session { + lastVisit?: string userId: number }