feat(shell,api,keys): firefly v1 shell over the api with cookie auth, period, palette and keymap
This commit is contained in:
1 parent
ae4de7911b
commit
c74dac3e00
37 files changed
+58163
-79
No files matched your search
+202
-42
@@ -1,56 +1,216 @@
|
||||
import { PUBLIC_API_BASE_URL } from "$app/env/public";
|
||||
import createClient, { type Middleware } from "openapi-fetch";
|
||||
import type { components, paths } from "./schema.js";
|
||||
|
||||
const BASE = PUBLIC_API_BASE_URL;
|
||||
export type Schemas = components["schemas"];
|
||||
|
||||
const XSRF_COOKIE = /(?:^|;\s*)XSRF-TOKEN=([^;]*)/;
|
||||
const RETURN_KEY = "ffui.returnTo";
|
||||
const LOOP_GUARD_MS = 10_000;
|
||||
const PING_EVERY_MS = 15 * 60_000;
|
||||
const PING_STALE_MS = 5 * 60_000;
|
||||
|
||||
export type SessionState = "ok" | "unauthenticated" | "mfa";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
errors: Record<string, string[]>;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
constructor(
|
||||
status: number,
|
||||
message: string,
|
||||
errors: Record<string, string[]> = {}
|
||||
) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown> | unknown[];
|
||||
export function xsrfToken(): string {
|
||||
const match = document.cookie.match(XSRF_COOKIE);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
async function handle<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204) {
|
||||
export function apiHeaders(json = true): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-XSRF-TOKEN": xsrfToken(),
|
||||
};
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
let lastPing = 0;
|
||||
|
||||
export async function ping(): Promise<SessionState> {
|
||||
lastPing = Date.now();
|
||||
const res = await fetch("/json/intro/ffui", {
|
||||
credentials: "same-origin",
|
||||
headers: apiHeaders(false),
|
||||
});
|
||||
if (res.status === 401 || res.redirected) {
|
||||
return "unauthenticated";
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
return Array.isArray(body) ? "ok" : "mfa";
|
||||
}
|
||||
|
||||
export function leaveTo(state: Exclude<SessionState, "ok">) {
|
||||
const raw = sessionStorage.getItem(RETURN_KEY);
|
||||
const previous = raw ? (JSON.parse(raw) as { at: number }) : null;
|
||||
if (previous && Date.now() - previous.at < LOOP_GUARD_MS) {
|
||||
throw new ApiError(401, "Session expired");
|
||||
}
|
||||
const path = location.pathname + location.search + location.hash;
|
||||
sessionStorage.setItem(RETURN_KEY, JSON.stringify({ at: Date.now(), path }));
|
||||
location.assign(state === "mfa" ? "/profile" : "/login");
|
||||
}
|
||||
|
||||
export function takeReturnPath(): string | null {
|
||||
const raw = sessionStorage.getItem(RETURN_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
sessionStorage.removeItem(RETURN_KEY);
|
||||
const { path } = JSON.parse(raw) as { path: string };
|
||||
return path === location.pathname + location.search ? null : path;
|
||||
}
|
||||
|
||||
export function keepAlive() {
|
||||
const refresh = () => {
|
||||
if (Date.now() - lastPing > PING_STALE_MS) {
|
||||
ping().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
const timer = setInterval(() => ping().catch(() => undefined), PING_EVERY_MS);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
window.addEventListener("online", refresh);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
window.removeEventListener("online", refresh);
|
||||
};
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await fetch("/logout", {
|
||||
credentials: "same-origin",
|
||||
headers: apiHeaders(false),
|
||||
method: "POST",
|
||||
});
|
||||
location.assign("/login");
|
||||
}
|
||||
|
||||
async function toError(res: Response): Promise<ApiError> {
|
||||
const body = (await res
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null)) as {
|
||||
message?: string;
|
||||
errors?: Record<string, string[] | string>;
|
||||
} | null;
|
||||
const errors: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(body?.errors ?? {})) {
|
||||
errors[key] = Array.isArray(value) ? value : [value];
|
||||
}
|
||||
return new ApiError(res.status, body?.message ?? res.statusText, errors);
|
||||
}
|
||||
|
||||
function withHeaders(request: Request): Request {
|
||||
const json = !(request.body instanceof Blob) && request.method !== "GET";
|
||||
for (const [key, value] of Object.entries(apiHeaders(json))) {
|
||||
if (!request.headers.has(key) || key === "X-XSRF-TOKEN") {
|
||||
request.headers.set(key, value);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async function recover(request: Request, response: Response) {
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
const state = await ping();
|
||||
if (state === "ok" && request.method === "GET") {
|
||||
const retry = await fetch(withHeaders(request));
|
||||
if (retry.status !== 401) {
|
||||
return retry;
|
||||
}
|
||||
}
|
||||
if (state !== "ok") {
|
||||
leaveTo(state);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const auth: Middleware = {
|
||||
onRequest: ({ request }) => withHeaders(request),
|
||||
onResponse: ({ request, response }) => recover(request, response),
|
||||
};
|
||||
|
||||
export const client = createClient<paths>({
|
||||
baseUrl: "/api",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
client.use(auth);
|
||||
|
||||
interface Result<T> {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
response: Response;
|
||||
}
|
||||
|
||||
export async function unwrap<T>(promise: Promise<Result<T>>): Promise<T> {
|
||||
const { data, response } = await promise;
|
||||
if (!response.ok) {
|
||||
throw await toError(response);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export type Query = Record<string, unknown>;
|
||||
|
||||
export function apiUrl(path: string, query: Query = {}): URL {
|
||||
const url = new URL(`/api${path}`, location.origin);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
if (item !== undefined && item !== null && item !== "") {
|
||||
url.searchParams.append(key, String(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
{
|
||||
query,
|
||||
body,
|
||||
...init
|
||||
}: Omit<RequestInit, "body"> & { query?: Query; body?: unknown } = {}
|
||||
): Promise<T> {
|
||||
const payload =
|
||||
body === undefined || body instanceof Blob ? body : JSON.stringify(body);
|
||||
const request = withHeaders(
|
||||
new Request(apiUrl(path, query), {
|
||||
credentials: "same-origin",
|
||||
...init,
|
||||
body: payload as BodyInit | undefined,
|
||||
})
|
||||
);
|
||||
const response = await recover(request.clone(), await fetch(request));
|
||||
if (!response.ok) {
|
||||
throw await toError(response);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
if (res.ok) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const { detail: parsed } = (await res.json()) as { detail?: string };
|
||||
if (parsed) {
|
||||
detail = parsed;
|
||||
}
|
||||
} catch {
|
||||
detail = res.statusText;
|
||||
}
|
||||
throw new ApiError(res.status, detail);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Json
|
||||
): Promise<T> {
|
||||
const init: RequestInit = { credentials: "include", method };
|
||||
if (body !== undefined) {
|
||||
init.headers = { "Content-Type": "application/json" };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(BASE + path, init);
|
||||
return handle<T>(res);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
del: <T>(path: string) => request<T>("DELETE", path),
|
||||
get: <T>(path: string) => request<T>("GET", path),
|
||||
patch: <T>(path: string, body?: Json) => request<T>("PATCH", path, body),
|
||||
post: <T>(path: string, body?: Json) => request<T>("POST", path, body),
|
||||
put: <T>(path: string, body?: Json) => request<T>("PUT", path, body),
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { QueryClient } from "@tanstack/svelte-query";
|
||||
import { api, type Query } from "./client.js";
|
||||
import type {
|
||||
Account,
|
||||
Bill,
|
||||
Budget,
|
||||
Category,
|
||||
Item,
|
||||
ObjectGroup,
|
||||
Page,
|
||||
PiggyBank,
|
||||
Tag,
|
||||
} from "./types.js";
|
||||
|
||||
const STALE_MS = 60_000;
|
||||
const ALL_LIMIT = 1000;
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: STALE_MS,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export async function all<T>(
|
||||
path: string,
|
||||
query: Query = {}
|
||||
): Promise<Item<T>[]> {
|
||||
const first = await api<Page<T>>(path, {
|
||||
query: { ...query, limit: ALL_LIMIT, page: 1 },
|
||||
});
|
||||
const pages = first.meta.pagination?.total_pages ?? 1;
|
||||
if (pages <= 1) {
|
||||
return first.data;
|
||||
}
|
||||
const rest = await Promise.all(
|
||||
Array.from({ length: pages - 1 }, (_, i) =>
|
||||
api<Page<T>>(path, { query: { ...query, limit: ALL_LIMIT, page: i + 2 } })
|
||||
)
|
||||
);
|
||||
return [...first.data, ...rest.flatMap((page) => page.data)];
|
||||
}
|
||||
|
||||
export const lists = {
|
||||
accounts: (type = "all") => ({
|
||||
queryFn: () => all<Account>("/v1/accounts", { type }),
|
||||
queryKey: ["accounts", type],
|
||||
}),
|
||||
bills: () => ({
|
||||
queryFn: () => all<Bill>("/v1/bills"),
|
||||
queryKey: ["bills"],
|
||||
}),
|
||||
budgets: () => ({
|
||||
queryFn: () => all<Budget>("/v1/budgets"),
|
||||
queryKey: ["budgets"],
|
||||
}),
|
||||
categories: () => ({
|
||||
queryFn: () => all<Category>("/v1/categories"),
|
||||
queryKey: ["categories"],
|
||||
}),
|
||||
objectGroups: () => ({
|
||||
queryFn: () => all<ObjectGroup>("/v1/object-groups"),
|
||||
queryKey: ["object-groups"],
|
||||
}),
|
||||
piggyBanks: () => ({
|
||||
queryFn: () => all<PiggyBank>("/v1/piggy-banks"),
|
||||
queryKey: ["piggy-banks"],
|
||||
}),
|
||||
tags: () => ({
|
||||
queryFn: () => all<Tag>("/v1/tags"),
|
||||
queryKey: ["tags"],
|
||||
}),
|
||||
};
|
||||
|
||||
export function invalidate() {
|
||||
return queryClient.invalidateQueries();
|
||||
}
|
||||
Vendored
+28401
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,65 @@
|
||||
import type { components } from "./schema.js";
|
||||
|
||||
type S = components["schemas"];
|
||||
|
||||
export interface Item<T> {
|
||||
attributes: T;
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
export interface Page<T> {
|
||||
data: Item<T>[];
|
||||
meta: {
|
||||
pagination?: {
|
||||
total: number;
|
||||
count: number;
|
||||
per_page: number;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface One<T> {
|
||||
data: Item<T>;
|
||||
}
|
||||
|
||||
export type Account = S["AccountProperties"];
|
||||
export type Attachment = S["AttachmentProperties"];
|
||||
export type AvailableBudget = S["AvailableBudgetProperties"];
|
||||
export type Bill = S["BillProperties"];
|
||||
export type Budget = S["BudgetProperties"];
|
||||
export type BudgetLimit = S["BudgetLimitProperties"];
|
||||
export type Category = S["CategoryProperties"];
|
||||
export type Currency = S["CurrencyProperties"];
|
||||
export type ExchangeRate = S["CurrencyExchangeProperties"];
|
||||
export type LinkType = S["LinkType"];
|
||||
export type ObjectGroup = S["ObjectGroup"];
|
||||
export type PiggyBank = S["PiggyBankProperties"];
|
||||
export type PiggyBankEvent = S["PiggyBankEventProperties"];
|
||||
export type Preference = S["Preference"];
|
||||
export type Recurrence = S["RecurrenceProperties"];
|
||||
export type Rule = S["Rule"];
|
||||
export type RuleGroup = S["RuleGroup"];
|
||||
export type Tag = S["TagModel"];
|
||||
export type TransactionGroup = S["Transaction"];
|
||||
export type Split = S["TransactionSplit"];
|
||||
export type SplitStore = S["TransactionSplitStore"];
|
||||
export type TransactionStore = S["TransactionStore"];
|
||||
export type TransactionLink = S["TransactionLink"];
|
||||
export type UserGroup = S["UserGroupReadAttributes"];
|
||||
export type Webhook = S["WebhookProperties"];
|
||||
export type ChartDataSet = S["ChartDataSet"];
|
||||
export type InsightGroupEntry = S["InsightGroupEntry"];
|
||||
export type InsightTotalEntry = S["InsightTotalEntry"];
|
||||
export type BasicSummary = S["BasicSummary"];
|
||||
export type BasicSummaryEntry = S["BasicSummaryEntry"];
|
||||
export type SystemInfo = S["SystemInfo"];
|
||||
|
||||
export type TransactionType =
|
||||
| "withdrawal"
|
||||
| "deposit"
|
||||
| "transfer"
|
||||
| "reconciliation"
|
||||
| "opening balance";
|
||||
|
||||
export type AccountType = "asset" | "expense" | "revenue" | "liabilities";
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
||||
|
||||
export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
|
||||
|
||||
let {
|
||||
amount,
|
||||
code,
|
||||
symbol,
|
||||
decimals,
|
||||
tone = "auto",
|
||||
signed = false,
|
||||
class: className = "",
|
||||
}: Money & { tone?: Tone; signed?: boolean; class?: string } = $props();
|
||||
|
||||
const value = $derived(toNumber(amount));
|
||||
const resolved = $derived.by(() => {
|
||||
if (tone !== "auto") {
|
||||
return tone;
|
||||
}
|
||||
if (value > 0) {
|
||||
return "income";
|
||||
}
|
||||
return value < 0 ? "expense" : "neutral";
|
||||
});
|
||||
const color = $derived(
|
||||
{
|
||||
expense: "text-expense",
|
||||
income: "text-income",
|
||||
neutral: "text-cladd-fg-softer",
|
||||
transfer: "text-transfer",
|
||||
}[resolved]
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="num {color} {className}"
|
||||
>{formatMoney({ amount, code, decimals, symbol }, signed)}</span
|
||||
>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
href,
|
||||
tools,
|
||||
footer,
|
||||
children,
|
||||
class: className = "",
|
||||
body = "p-3",
|
||||
}: {
|
||||
title?: string;
|
||||
href?: string;
|
||||
tools?: Snippet;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
body?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {className}"
|
||||
>
|
||||
{#if title || tools}
|
||||
<header
|
||||
class="flex min-h-10 items-center justify-between gap-3 border-cladd-outline border-b px-3 py-1.5"
|
||||
>
|
||||
{#if title}
|
||||
<h2 class="truncate font-semibold text-[13px] text-cladd-fg">
|
||||
{#if href}
|
||||
<a class="hover:underline" {href}>{title}</a>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if tools}
|
||||
<div class="flex shrink-0 items-center gap-1.5">{@render tools()}</div>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
<div class="min-h-0 flex-1 {body}">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if footer}
|
||||
<footer
|
||||
class="flex items-center gap-2 border-cladd-outline border-t px-3 py-2 text-[12px]"
|
||||
>
|
||||
{@render footer()}
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const ARM_MS = 3000;
|
||||
|
||||
let {
|
||||
onconfirm,
|
||||
children,
|
||||
armed = $bindable(false),
|
||||
confirmLabel = "Press again to delete",
|
||||
class: className = "",
|
||||
disabled = false,
|
||||
}: {
|
||||
onconfirm: () => void | Promise<void>;
|
||||
children: Snippet;
|
||||
armed?: boolean;
|
||||
confirmLabel?: string;
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
export async function press() {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
if (!armed) {
|
||||
armed = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (armed = false), ARM_MS);
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
busy = true;
|
||||
try {
|
||||
await onconfirm();
|
||||
} finally {
|
||||
busy = false;
|
||||
armed = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
aria-live="polite"
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] {armed
|
||||
? "cladd-color-red text-cladd-primary"
|
||||
: "text-cladd-fg"} {className}"
|
||||
disabled={disabled || busy}
|
||||
onblur={() => (armed = false)}
|
||||
onclick={press}
|
||||
type="button"
|
||||
>
|
||||
{#if armed}
|
||||
{confirmLabel}
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ui } from "#lib/ui.svelte.js";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="border-cladd-outline border-b bg-cladd-surface px-6 py-3 text-[13px]"
|
||||
>
|
||||
<button onclick={() => (ui.quickEntry = false)} type="button">Close</button>
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { keys, prettyKeys } from "#lib/keys.svelte.js";
|
||||
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const map = new Map<string, { keys: string; label: string }[]>();
|
||||
const seen = new Set<string>();
|
||||
for (const binding of [...keys.all].reverse()) {
|
||||
if (binding.hidden || seen.has(binding.keys)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(binding.keys);
|
||||
const group = binding.group ?? "Page";
|
||||
map.set(group, [...(map.get(group) ?? []), binding]);
|
||||
}
|
||||
return [...map.entries()].reverse();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (keys.sheetOpen && dialog && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!keys.sheetOpen && dialog?.open) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
aria-label="Keyboard shortcuts"
|
||||
class="cladd-surface cladd-outline m-auto w-[min(920px,calc(100vw-2rem))] rounded-cladd-panel p-0 text-cladd-fg shadow-cladd-popover backdrop:bg-black/30"
|
||||
onclick={(event) => event.target === dialog && (keys.sheetOpen = false)}
|
||||
onclose={() => (keys.sheetOpen = false)}
|
||||
onkeydown={(event) => event.key === "?" && (keys.sheetOpen = false)}
|
||||
bind:this={dialog}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-cladd-outline border-b px-5 py-3"
|
||||
>
|
||||
<h2 class="font-semibold text-[15px]">Keyboard shortcuts</h2>
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
>Every shortcut is also in the palette,
|
||||
<span class="kbd">{prettyKeys("mod+k")[0]}</span></span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="grid max-h-[70vh] grid-cols-1 gap-x-8 gap-y-5 overflow-y-auto px-5 py-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{#each groups as [group, bindings] (group)}
|
||||
<section class="flex flex-col gap-1">
|
||||
<h3
|
||||
class="pb-1 font-semibold text-[11px] text-cladd-fg-softer uppercase tracking-[0.08em]"
|
||||
>
|
||||
{group}
|
||||
</h3>
|
||||
{#each bindings as binding (binding.keys)}
|
||||
<div class="flex h-7 items-center justify-between gap-3 text-[13px]">
|
||||
<span class="truncate">{binding.label}</span>
|
||||
<span class="flex shrink-0 gap-1">
|
||||
{#each prettyKeys(binding.keys) as key, i (i)}
|
||||
<span class="kbd">{key}</span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
export interface Crumb {
|
||||
href?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
crumbs = [],
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
crumbs?: Crumb[];
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} · Firefly III</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 pb-4">
|
||||
<div class="flex min-w-0 items-baseline gap-3">
|
||||
<h1
|
||||
class="truncate font-semibold text-[22px] text-cladd-fg tracking-[-0.02em]"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{#if subtitle}
|
||||
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{@render actions?.()}
|
||||
<ol
|
||||
aria-label="Breadcrumbs"
|
||||
class="flex items-center gap-1.5 text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
<li><a class="hover:text-cladd-fg hover:underline" href="/">Home</a></li>
|
||||
{#each crumbs as crumb (crumb.label)}
|
||||
<li aria-hidden="true" class="text-cladd-fg-softest">/</li>
|
||||
<li>
|
||||
{#if crumb.href}
|
||||
<a class="hover:text-cladd-fg hover:underline" href={crumb.href}
|
||||
>{crumb.label}</a
|
||||
>
|
||||
{:else}
|
||||
<span class="text-cladd-fg">{crumb.label}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script lang="ts">
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
import { keys, prettyKeys } from "#lib/keys.svelte.js";
|
||||
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
|
||||
import { ui } from "#lib/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Entry {
|
||||
group: string;
|
||||
hint?: string;
|
||||
id: string;
|
||||
keys?: string;
|
||||
label: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let input = $state<HTMLInputElement>();
|
||||
let cursor = $state(0);
|
||||
|
||||
const accounts = createQuery(() => ({
|
||||
...lists.accounts("all"),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
const budgets = createQuery(() => ({
|
||||
...lists.budgets(),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
const categories = createQuery(() => ({
|
||||
...lists.categories(),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
const tags = createQuery(() => ({ ...lists.tags(), enabled: ui.palette }));
|
||||
const bills = createQuery(() => ({ ...lists.bills(), enabled: ui.palette }));
|
||||
const piggies = createQuery(() => ({
|
||||
...lists.piggyBanks(),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
|
||||
function link(href: string, old = false) {
|
||||
return () => {
|
||||
if (old) {
|
||||
location.assign(href);
|
||||
} else {
|
||||
goto(href);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const ACCOUNT_GROUP: Record<string, string> = {
|
||||
asset: "Asset account",
|
||||
expense: "Expense account",
|
||||
liabilities: "Liability",
|
||||
revenue: "Revenue account",
|
||||
};
|
||||
|
||||
function entity(
|
||||
kind: string,
|
||||
group: string,
|
||||
href: string,
|
||||
id: string,
|
||||
label: string | undefined,
|
||||
hint?: string
|
||||
): Entry {
|
||||
return {
|
||||
group,
|
||||
hint,
|
||||
id: `${kind}:${id}`,
|
||||
label: label ?? "",
|
||||
run: link(`${href}/${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
const staticEntries: Entry[] = [
|
||||
...allLinks().map((nav) => ({
|
||||
group: "Page",
|
||||
id: `page:${nav.href}`,
|
||||
keys: nav.keys,
|
||||
label: nav.label,
|
||||
run: link(nav.href, nav.old),
|
||||
})),
|
||||
...CREATE_LINKS.map((create) => ({
|
||||
group: "Create",
|
||||
id: `create:${create.href}`,
|
||||
label: create.label,
|
||||
run: link(create.href, create.old),
|
||||
})),
|
||||
];
|
||||
|
||||
const commandEntries = $derived<Entry[]>(
|
||||
keys.all
|
||||
.filter((binding) => !binding.hidden && binding.group !== "Navigation")
|
||||
.map((binding) => ({
|
||||
group: binding.group ?? "Command",
|
||||
id: `key:${binding.keys}:${binding.label}`,
|
||||
keys: binding.keys,
|
||||
label: binding.label,
|
||||
run: () => binding.run(new KeyboardEvent("keydown")),
|
||||
}))
|
||||
);
|
||||
|
||||
const entityEntries = $derived<Entry[]>([
|
||||
...(accounts.data ?? []).map((item) =>
|
||||
entity(
|
||||
"account",
|
||||
ACCOUNT_GROUP[item.attributes.type] ?? "Account",
|
||||
"/accounts/show",
|
||||
item.id,
|
||||
item.attributes.name,
|
||||
item.attributes.active ? undefined : "inactive"
|
||||
)
|
||||
),
|
||||
...(budgets.data ?? []).map((item) =>
|
||||
entity("budget", "Budget", "/budgets/show", item.id, item.attributes.name)
|
||||
),
|
||||
...(categories.data ?? []).map((item) =>
|
||||
entity(
|
||||
"category",
|
||||
"Category",
|
||||
"/categories/show",
|
||||
item.id,
|
||||
item.attributes.name
|
||||
)
|
||||
),
|
||||
...(tags.data ?? []).map((item) =>
|
||||
entity("tag", "Tag", "/tags/show", item.id, item.attributes.tag)
|
||||
),
|
||||
...(bills.data ?? []).map((item) =>
|
||||
entity(
|
||||
"bill",
|
||||
"Subscription",
|
||||
"/subscriptions/show",
|
||||
item.id,
|
||||
item.attributes.name
|
||||
)
|
||||
),
|
||||
...(piggies.data ?? []).map((item) =>
|
||||
entity(
|
||||
"piggy",
|
||||
"Piggy bank",
|
||||
"/piggy-banks/show",
|
||||
item.id,
|
||||
item.attributes.name
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
const entries = $derived([
|
||||
...staticEntries,
|
||||
...commandEntries,
|
||||
...entityEntries,
|
||||
]);
|
||||
|
||||
const results = $derived.by<Entry[]>(() => {
|
||||
const query = ui.paletteQuery;
|
||||
const found = fuzzyFilter(
|
||||
entries,
|
||||
query,
|
||||
(e) => `${e.label} ${e.group}`,
|
||||
60
|
||||
);
|
||||
if (query.trim()) {
|
||||
found.push({
|
||||
group: "Search",
|
||||
id: "search",
|
||||
label: `Search transactions for “${query.trim()}”`,
|
||||
run: link(`/search?search=${encodeURIComponent(query.trim())}`),
|
||||
});
|
||||
}
|
||||
return found;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ui.palette && dialog && !dialog.open) {
|
||||
cursor = 0;
|
||||
dialog.showModal();
|
||||
input?.select();
|
||||
} else if (!ui.palette && dialog?.open) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ui.paletteQuery !== undefined) {
|
||||
cursor = 0;
|
||||
}
|
||||
});
|
||||
|
||||
function choose(entry: Entry | undefined) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
ui.palette = false;
|
||||
entry.run();
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
cursor = (cursor + delta + results.length) % results.length;
|
||||
document
|
||||
.getElementById(`palette-${cursor}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
const ctrl = event.ctrlKey || event.metaKey;
|
||||
if (
|
||||
event.key === "ArrowDown" ||
|
||||
(ctrl && event.key === "j") ||
|
||||
(ctrl && event.key === "n")
|
||||
) {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (
|
||||
event.key === "ArrowUp" ||
|
||||
(ctrl && event.key === "k") ||
|
||||
(ctrl && event.key === "p")
|
||||
) {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
choose(results[cursor]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
aria-label="Command palette"
|
||||
class="cladd-surface cladd-outline m-auto mt-[12vh] w-[min(640px,calc(100vw-2rem))] rounded-cladd-panel p-0 text-cladd-fg shadow-cladd-popover backdrop:bg-black/30"
|
||||
onclose={() => (ui.palette = false)}
|
||||
bind:this={dialog}
|
||||
>
|
||||
<div class="flex items-center gap-2 border-cladd-outline border-b px-4">
|
||||
<Search class="size-4 text-cladd-fg-softer" />
|
||||
<input
|
||||
aria-activedescendant={`palette-${cursor}`}
|
||||
aria-controls="palette-list"
|
||||
aria-expanded="true"
|
||||
class="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-cladd-fg-softer"
|
||||
{onkeydown}
|
||||
placeholder="Go to page, account, budget, category, tag, or run a command"
|
||||
role="combobox"
|
||||
bind:this={input}
|
||||
bind:value={ui.paletteQuery}
|
||||
>
|
||||
<span class="kbd">Esc</span>
|
||||
</div>
|
||||
<ul class="max-h-[56vh] overflow-y-auto p-1.5" id="palette-list">
|
||||
{#each results as entry, index (entry.id)}
|
||||
<li>
|
||||
<button
|
||||
class="flex h-9 w-full cursor-pointer items-center gap-3 rounded-cladd-md px-3 text-left {index ===
|
||||
cursor
|
||||
? "bg-row-cursor"
|
||||
: ""}"
|
||||
id={`palette-${index}`}
|
||||
onclick={() => choose(entry)}
|
||||
onmousemove={() => (cursor = index)}
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-[13px]">{entry.label}</span>
|
||||
{#if entry.hint}
|
||||
<span class="text-[11px] text-cladd-fg-softer">{entry.hint}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="w-28 shrink-0 text-right text-[11px] text-cladd-fg-softer"
|
||||
>{entry.group}</span
|
||||
>
|
||||
<span class="flex w-16 shrink-0 justify-end gap-1">
|
||||
{#if entry.keys}
|
||||
{#each prettyKeys(entry.keys) as key, i (i)}
|
||||
<span class="kbd">{key}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-3 py-6 text-center text-[13px] text-cladd-fg-soft">
|
||||
Nothing matches. Try fewer letters.
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</dialog>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import CalendarRange from "@lucide/svelte/icons/calendar-range";
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import { Popover } from "bits-ui";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
|
||||
let open = $state(false);
|
||||
let start = $state(period.start);
|
||||
let end = $state(period.end);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
({ start, end } = period);
|
||||
}
|
||||
});
|
||||
|
||||
function apply(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (start && end && start <= end) {
|
||||
period.set({ end, start });
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center rounded-cladd-md bg-black/10 text-on-brand">
|
||||
<button
|
||||
aria-label="Previous period"
|
||||
class="grid size-8 place-items-center rounded-l-cladd-md hover:bg-black/10"
|
||||
onclick={() => period.shift(-1)}
|
||||
title="Previous period ["
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</button>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
class="flex h-8 items-center gap-2 px-2 font-medium text-[13px] hover:bg-black/10"
|
||||
title="Choose period"
|
||||
>
|
||||
<CalendarRange class="size-3.5 opacity-80" />
|
||||
<span class="num">{period.label}</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-level-1 cladd-anim-popover z-50 w-72 rounded-cladd-lg p-2 text-cladd-fg shadow-cladd-popover outline-none"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
{#each period.presets() as preset (preset.label)}
|
||||
{@const current =
|
||||
preset.range.start === period.start &&
|
||||
preset.range.end === period.end}
|
||||
<button
|
||||
class="flex h-8 items-center justify-between rounded-cladd-sm px-2.5 text-left text-[13px] hover:bg-cladd-surface-next {current
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
onclick={() => {
|
||||
period.set(preset.range);
|
||||
open = false;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="mt-2 flex flex-col gap-2 border-cladd-outline border-t px-1 pt-3"
|
||||
onsubmit={apply}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex flex-col gap-1 text-[11px] text-cladd-fg-soft">
|
||||
Start
|
||||
<input
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
type="date"
|
||||
bind:value={start}
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-[11px] text-cladd-fg-soft">
|
||||
End
|
||||
<input
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
type="date"
|
||||
bind:value={end}
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand h-8 rounded-cladd-sm font-semibold text-[12px] text-cladd-primary"
|
||||
type="submit"
|
||||
>
|
||||
Apply custom range
|
||||
</button>
|
||||
</form>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<button
|
||||
aria-label="Next period"
|
||||
class="grid size-8 place-items-center rounded-r-cladd-md hover:bg-black/10"
|
||||
onclick={() => period.shift(1)}
|
||||
title="Next period ]"
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import Eye from "@lucide/svelte/icons/eye";
|
||||
import EyeOff from "@lucide/svelte/icons/eye-off";
|
||||
import History from "@lucide/svelte/icons/history";
|
||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
||||
import PanelLeft from "@lucide/svelte/icons/panel-left";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import { setMode, userPrefersMode } from "mode-watcher";
|
||||
import type { Snippet } from "svelte";
|
||||
import { logout } from "#lib/api/client.js";
|
||||
import { invalidate } from "#lib/api/lists.js";
|
||||
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||
import { isEditable, keys, useKeys } from "#lib/keys.svelte.js";
|
||||
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { session, TESTED_VERSION } from "#lib/session.svelte.js";
|
||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||
import { ui } from "#lib/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import Cheatsheet from "./Cheatsheet.svelte";
|
||||
import Palette from "./Palette.svelte";
|
||||
import PeriodPicker from "./PeriodPicker.svelte";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
|
||||
let { children, quick }: { children: Snippet; quick?: Snippet } = $props();
|
||||
|
||||
function openOld(sticky = false) {
|
||||
location.assign(oldUiUrl(page.url.href, sticky));
|
||||
}
|
||||
|
||||
async function togglePrivacy() {
|
||||
await session.setPreference("anonymous", !session.anonymous);
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
const order = ["system", "light", "dark"] as const;
|
||||
const current = userPrefersMode.current ?? "system";
|
||||
setMode(order[(order.indexOf(current) + 1) % order.length]);
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
const input = document.getElementById("sidebar-search");
|
||||
if (input && input.offsetParent !== null) {
|
||||
input.focus();
|
||||
} else {
|
||||
goto("/search");
|
||||
}
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Global",
|
||||
input: true,
|
||||
keys: "mod+k",
|
||||
label: "Command palette",
|
||||
run: () => ui.openPalette(),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "?",
|
||||
label: "Keyboard shortcuts",
|
||||
run: () => (keys.sheetOpen = !keys.sheetOpen),
|
||||
},
|
||||
{ group: "Global", keys: "/", label: "Search", run: focusSearch },
|
||||
{
|
||||
group: "Global",
|
||||
keys: "n",
|
||||
label: "Quick entry",
|
||||
run: () => (ui.quickEntry = true),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "N",
|
||||
label: "New transaction (full editor)",
|
||||
run: () => goto("/transactions/create/withdrawal"),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "O",
|
||||
label: "Open this page in the old interface",
|
||||
run: () => openOld(),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "g O",
|
||||
label: "Stay in the old interface",
|
||||
run: () => openOld(true),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "\\",
|
||||
label: "Toggle sidebar",
|
||||
run: () => ui.toggleSidebar(),
|
||||
},
|
||||
{
|
||||
group: "Period",
|
||||
keys: "[",
|
||||
label: "Previous period",
|
||||
run: () => period.shift(-1),
|
||||
},
|
||||
{
|
||||
group: "Period",
|
||||
keys: "]",
|
||||
label: "Next period",
|
||||
run: () => period.shift(1),
|
||||
},
|
||||
{
|
||||
group: "Period",
|
||||
keys: ".",
|
||||
label: "Current period",
|
||||
run: () => period.reset(),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
hidden: true,
|
||||
keys: "g g",
|
||||
label: "Top of page",
|
||||
run: () => window.scrollTo({ top: 0 }),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
hidden: true,
|
||||
keys: "G",
|
||||
label: "Bottom of page",
|
||||
run: () => window.scrollTo({ top: document.body.scrollHeight }),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
hidden: true,
|
||||
input: true,
|
||||
keys: "Escape",
|
||||
label: "Leave field",
|
||||
run: (event) => {
|
||||
if (isEditable(event.target)) {
|
||||
(event.target as HTMLElement).blur();
|
||||
}
|
||||
ui.mobileNav = false;
|
||||
ui.quickEntry = false;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "Settings",
|
||||
keys: "g T",
|
||||
label: "Cycle theme: system, light, dark",
|
||||
run: cycleTheme,
|
||||
},
|
||||
{
|
||||
group: "Settings",
|
||||
keys: "g P",
|
||||
label: "Toggle privacy mode",
|
||||
run: togglePrivacy,
|
||||
},
|
||||
{ group: "Global", keys: "g q", label: "Logout", run: logout },
|
||||
...allLinks()
|
||||
.filter((link) => link.keys)
|
||||
.map((link) => ({
|
||||
group: "Navigation",
|
||||
keys: link.keys as string,
|
||||
label: `Go to ${link.label.toLowerCase()}`,
|
||||
run: () => (link.old ? location.assign(link.href) : goto(link.href)),
|
||||
})),
|
||||
{
|
||||
group: "Navigation",
|
||||
keys: "g f",
|
||||
label: "Go to search",
|
||||
run: () => goto("/search"),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh bg-cladd-bg">
|
||||
<header
|
||||
class="sticky top-0 z-40 flex h-12 items-center bg-brand text-on-brand shadow-[0_1px_0_rgb(0_0_0/0.08)]"
|
||||
>
|
||||
<a
|
||||
class="flex h-full shrink-0 items-center gap-2 px-4 text-[17px] tracking-[-0.01em] {ui.collapsed
|
||||
? "lg:w-14 lg:justify-center lg:px-0"
|
||||
: "lg:w-60"}"
|
||||
href="/"
|
||||
>
|
||||
{#if ui.collapsed}
|
||||
<strong class="hidden lg:inline">FF</strong>
|
||||
<span class="lg:hidden"><strong>Firefly</strong>III</span>
|
||||
{:else}
|
||||
<span><strong>Firefly</strong>III</span>
|
||||
{/if}
|
||||
</a>
|
||||
<button
|
||||
aria-label="Toggle sidebar"
|
||||
class="grid size-12 shrink-0 place-items-center hover:bg-black/10"
|
||||
onclick={() => ui.toggleSidebar()}
|
||||
title="Toggle sidebar \"
|
||||
type="button"
|
||||
>
|
||||
<PanelLeft class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
class="ml-1 hidden h-8 min-w-0 items-center gap-2 rounded-cladd-md bg-black/10 px-3 text-[13px] text-on-brand/80 hover:bg-black/15 md:flex md:w-64 xl:w-80"
|
||||
onclick={() => ui.openPalette()}
|
||||
type="button"
|
||||
>
|
||||
<span class="flex-1 truncate text-left">Jump to anything</span>
|
||||
<span class="rounded bg-black/15 px-1.5 py-0.5 font-mono text-[11px]"
|
||||
>⌘K</span
|
||||
>
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-1 pr-2">
|
||||
<button
|
||||
aria-label={session.anonymous ? "Show amounts" : "Hide amounts"}
|
||||
aria-pressed={session.anonymous}
|
||||
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
|
||||
onclick={togglePrivacy}
|
||||
title={session.anonymous
|
||||
? "Amounts hidden. Show amounts"
|
||||
: "Hide amounts"}
|
||||
type="button"
|
||||
>
|
||||
{#if session.anonymous}
|
||||
<EyeOff class="size-4" />
|
||||
{:else}
|
||||
<Eye class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Keyboard shortcuts"
|
||||
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
|
||||
onclick={() => (keys.sheetOpen = true)}
|
||||
title="Keyboard shortcuts ?"
|
||||
type="button"
|
||||
>
|
||||
<Keyboard class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Open this page in the old interface"
|
||||
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
|
||||
onclick={() => openOld()}
|
||||
title="Open this page in the old interface Shift+O"
|
||||
type="button"
|
||||
>
|
||||
<History class="size-4" />
|
||||
</button>
|
||||
<PeriodPicker />
|
||||
<span class="hidden px-2 text-[12px] text-on-brand/85 xl:inline"
|
||||
>{session.email}</span
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
aria-label="Create new"
|
||||
class="grid size-8 place-items-center rounded-cladd-md hover:bg-black/10"
|
||||
title="Create new"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenuContent align="end" class="w-60">
|
||||
{#each CREATE_LINKS as link (link.href)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
link.old ? location.assign(link.href) : goto(link.href)}
|
||||
>
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
{/each}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex">
|
||||
<aside
|
||||
class="fixed top-12 bottom-0 z-30 w-60 shrink-0 border-cladd-outline border-r bg-cladd-surface transition-transform lg:sticky lg:h-[calc(100dvh-3rem)] lg:translate-x-0 {ui.collapsed
|
||||
? "lg:w-14"
|
||||
: "lg:w-60"} {ui.mobileNav ? "translate-x-0" : "-translate-x-full"}"
|
||||
>
|
||||
<Sidebar
|
||||
collapsed={ui.collapsed && !ui.mobileNav}
|
||||
onnavigate={() => (ui.mobileNav = false)}
|
||||
/>
|
||||
</aside>
|
||||
{#if ui.mobileNav}
|
||||
<button
|
||||
aria-label="Close menu"
|
||||
class="fixed inset-0 top-12 z-20 bg-black/30 lg:hidden"
|
||||
onclick={() => (ui.mobileNav = false)}
|
||||
type="button"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-[calc(100dvh-3rem)] min-w-0 flex-1 flex-col">
|
||||
{#if ui.quickEntry && quick}
|
||||
{@render quick()}
|
||||
{/if}
|
||||
<main class="mx-auto w-full max-w-[1920px] flex-1 px-4 py-4 lg:px-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
<footer
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-cladd-outline border-t px-4 py-2.5 text-[12px] text-cladd-fg-soft lg:px-6"
|
||||
>
|
||||
<span>
|
||||
Firefly III {session.version}
|
||||
{#if session.drift}
|
||||
<span class="text-warn"
|
||||
>· this interface was tested with {TESTED_VERSION}, some screens
|
||||
may misbehave</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5"
|
||||
>Press <span class="kbd">?</span> for shortcuts,
|
||||
<span class="kbd">Shift+O</span>
|
||||
for the old interface</span
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Palette />
|
||||
<Cheatsheet />
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import LogOut from "@lucide/svelte/icons/log-out";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import { logout } from "#lib/api/client.js";
|
||||
import { isActive, NAV, type NavItem } from "#lib/nav.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let {
|
||||
collapsed = false,
|
||||
onnavigate,
|
||||
}: { collapsed?: boolean; onnavigate?: () => void } = $props();
|
||||
|
||||
let query = $state("");
|
||||
let opened = $state<Record<string, boolean>>({});
|
||||
|
||||
const pathname = $derived(page.url.pathname);
|
||||
|
||||
function isOpen(item: NavItem) {
|
||||
return opened[item.label] ?? isActive(item, pathname);
|
||||
}
|
||||
|
||||
function toggle(item: NavItem) {
|
||||
opened[item.label] = !isOpen(item);
|
||||
}
|
||||
|
||||
function search(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
goto(`/search?search=${encodeURIComponent(query)}`);
|
||||
onnavigate?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
aria-label="Main"
|
||||
class="flex h-full flex-col gap-3 overflow-y-auto px-2 py-3"
|
||||
>
|
||||
{#if !collapsed}
|
||||
<search>
|
||||
<form class="relative px-1" onsubmit={search}>
|
||||
<Search
|
||||
class="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-cladd-fg-softer"
|
||||
/>
|
||||
<input
|
||||
class="cladd-cut cladd-focusable-input h-8 w-full rounded-cladd-md pr-2 pl-8 text-[13px] text-cladd-fg placeholder:text-cladd-fg-softer"
|
||||
id="sidebar-search"
|
||||
name="search"
|
||||
placeholder="Search..."
|
||||
type="search"
|
||||
bind:value={query}
|
||||
>
|
||||
</form>
|
||||
</search>
|
||||
{/if}
|
||||
|
||||
{#each NAV as section, index (index)}
|
||||
<div class="flex flex-col gap-px">
|
||||
{#if section.title && !collapsed}
|
||||
<div
|
||||
class="px-2.5 pt-1 pb-1 font-semibold text-[10.5px] text-cladd-fg-softer uppercase tracking-[0.08em]"
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
{/if}
|
||||
{#each section.items as item (item.label)}
|
||||
{@const active = isActive(item, pathname)}
|
||||
{#if item.children}
|
||||
<button
|
||||
aria-expanded={isOpen(item)}
|
||||
class="group flex h-8 w-full items-center gap-2.5 rounded-cladd-sm px-2.5 text-left text-cladd-fg hover:bg-cladd-surface-next {active
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
onclick={() => (collapsed ? goto(item.href) : toggle(item))}
|
||||
title={collapsed ? item.label : undefined}
|
||||
type="button"
|
||||
>
|
||||
<item.icon
|
||||
class="size-4 shrink-0 {active
|
||||
? "text-brand dark:text-(--ff-series-1)"
|
||||
: "text-cladd-fg-soft"}"
|
||||
/>
|
||||
{#if !collapsed}
|
||||
<span class="flex-1 truncate">{item.label}</span>
|
||||
<ChevronRight
|
||||
class="size-3.5 text-cladd-fg-softer transition-transform {isOpen(
|
||||
item
|
||||
)
|
||||
? "rotate-90"
|
||||
: ""}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isOpen(item) && !collapsed}
|
||||
<div
|
||||
class="mb-1 ml-[1.15rem] flex flex-col gap-px border-cladd-outline border-l pl-2"
|
||||
>
|
||||
{#each item.children as child (child.href)}
|
||||
{@const childActive = isActive(child, pathname)}
|
||||
<a
|
||||
aria-current={childActive ? "page" : undefined}
|
||||
class="flex h-7 items-center justify-between gap-2 rounded-cladd-sm px-2.5 text-[13px] hover:bg-cladd-surface-next {childActive
|
||||
? "bg-cladd-surface-next font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft"}"
|
||||
data-sveltekit-reload={child.old ? "" : undefined}
|
||||
href={child.href}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="truncate">{child.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
aria-current={active ? "page" : undefined}
|
||||
class="flex h-8 items-center gap-2.5 rounded-cladd-sm px-2.5 hover:bg-cladd-surface-next {active
|
||||
? "bg-cladd-surface-next font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg"}"
|
||||
data-sveltekit-reload={item.old ? "" : undefined}
|
||||
href={item.href}
|
||||
onclick={onnavigate}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<item.icon
|
||||
class="size-4 shrink-0 {active
|
||||
? "text-brand dark:text-(--ff-series-1)"
|
||||
: "text-cladd-fg-soft"}"
|
||||
/>
|
||||
{#if !collapsed}
|
||||
<span class="truncate">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="mt-auto flex h-8 items-center gap-2.5 rounded-cladd-sm px-2.5 text-left text-cladd-fg-soft hover:bg-cladd-surface-next"
|
||||
onclick={logout}
|
||||
title={collapsed ? "Logout" : undefined}
|
||||
type="button"
|
||||
>
|
||||
<LogOut class="size-4 shrink-0" />
|
||||
{#if !collapsed}
|
||||
<span>Logout</span>
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
@@ -0,0 +1,150 @@
|
||||
export interface Range {
|
||||
end: string;
|
||||
start: string;
|
||||
}
|
||||
|
||||
export type ViewRange =
|
||||
| "1D"
|
||||
| "1W"
|
||||
| "last7"
|
||||
| "1M"
|
||||
| "last30"
|
||||
| "MTD"
|
||||
| "3M"
|
||||
| "last90"
|
||||
| "QTD"
|
||||
| "6M"
|
||||
| "1Y"
|
||||
| "YTD"
|
||||
| "last365";
|
||||
|
||||
const DYNAMIC = new Set<ViewRange>([
|
||||
"last7",
|
||||
"last30",
|
||||
"last90",
|
||||
"last365",
|
||||
"MTD",
|
||||
"QTD",
|
||||
"YTD",
|
||||
]);
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export const iso = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
export function parse(value: string): Date {
|
||||
const [y, m, d] = value.slice(0, 10).split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export const today = () => iso(new Date());
|
||||
|
||||
export function addDays(value: string, days: number): string {
|
||||
const d = parse(value);
|
||||
d.setDate(d.getDate() + days);
|
||||
return iso(d);
|
||||
}
|
||||
|
||||
export function addMonths(value: string, months: number): string {
|
||||
const d = parse(value);
|
||||
const day = d.getDate();
|
||||
d.setDate(1);
|
||||
d.setMonth(d.getMonth() + months);
|
||||
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||
d.setDate(Math.min(day, last));
|
||||
return iso(d);
|
||||
}
|
||||
|
||||
export const isDynamic = (view: ViewRange) => DYNAMIC.has(view);
|
||||
|
||||
function startOf(value: string, view: ViewRange): string {
|
||||
const d = parse(value);
|
||||
switch (view) {
|
||||
case "1D":
|
||||
return value;
|
||||
case "1W": {
|
||||
const shift = (d.getDay() + 6) % 7;
|
||||
return addDays(value, -shift);
|
||||
}
|
||||
case "3M":
|
||||
return iso(
|
||||
new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1)
|
||||
);
|
||||
case "6M":
|
||||
return iso(new Date(d.getFullYear(), d.getMonth() < 6 ? 0 : 6, 1));
|
||||
case "1Y":
|
||||
return iso(new Date(d.getFullYear(), 0, 1));
|
||||
default:
|
||||
return iso(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||
}
|
||||
}
|
||||
|
||||
const LENGTH_MONTHS: Partial<Record<ViewRange, number>> = {
|
||||
"1M": 1,
|
||||
"1Y": 12,
|
||||
"3M": 3,
|
||||
"6M": 6,
|
||||
};
|
||||
|
||||
export function periodOf(value: string, view: ViewRange): Range {
|
||||
const now = today();
|
||||
switch (view) {
|
||||
case "last7":
|
||||
return { end: now, start: addDays(now, -7) };
|
||||
case "last30":
|
||||
return { end: now, start: addDays(now, -30) };
|
||||
case "last90":
|
||||
return { end: now, start: addDays(now, -90) };
|
||||
case "last365":
|
||||
return { end: now, start: addDays(now, -365) };
|
||||
case "MTD":
|
||||
return { end: now, start: startOf(now, "1M") };
|
||||
case "QTD":
|
||||
return { end: now, start: startOf(now, "3M") };
|
||||
case "YTD":
|
||||
return { end: now, start: startOf(now, "1Y") };
|
||||
case "1D":
|
||||
return { end: value, start: value };
|
||||
case "1W": {
|
||||
const start = startOf(value, "1W");
|
||||
return { end: addDays(start, 6), start };
|
||||
}
|
||||
default: {
|
||||
const start = startOf(value, view);
|
||||
const months = LENGTH_MONTHS[view] ?? 1;
|
||||
return { end: addDays(addMonths(start, months), -1), start };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shiftPeriod(range: Range, view: ViewRange, dir: 1 | -1): Range {
|
||||
const fixed = isDynamic(view) ? "1M" : view;
|
||||
const aligned = periodOf(range.start, fixed);
|
||||
if (aligned.start === range.start && aligned.end === range.end) {
|
||||
const anchor = dir === 1 ? addDays(range.end, 1) : addDays(range.start, -1);
|
||||
return periodOf(anchor, fixed);
|
||||
}
|
||||
const days = daysBetween(range.start, range.end) + 1;
|
||||
return {
|
||||
end: addDays(range.end, dir * days),
|
||||
start: addDays(range.start, dir * days),
|
||||
};
|
||||
}
|
||||
|
||||
export function daysBetween(a: string, b: string): number {
|
||||
return Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
export function eachDay(range: Range): string[] {
|
||||
const days: string[] = [];
|
||||
for (let d = range.start; d <= range.end; d = addDays(d, 1)) {
|
||||
days.push(d);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
export function contains(range: Range, value: string): boolean {
|
||||
const day = value.slice(0, 10);
|
||||
return day >= range.start && day <= range.end;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
export interface Money {
|
||||
amount: string | number | null | undefined;
|
||||
code?: string | null;
|
||||
decimals?: number | null;
|
||||
symbol?: string | null;
|
||||
}
|
||||
|
||||
const settings: { anonymous: boolean; locale: string } = {
|
||||
anonymous: false,
|
||||
locale: "en-GB",
|
||||
};
|
||||
|
||||
export function configureFormat(locale: string, anonymous: boolean) {
|
||||
settings.locale = locale;
|
||||
settings.anonymous = anonymous;
|
||||
}
|
||||
|
||||
export const currentLocale = () => settings.locale;
|
||||
|
||||
const numberFormats = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
function numberFormat(decimals: number, signed: boolean): Intl.NumberFormat {
|
||||
const key = `${settings.locale}|${decimals}|${signed}`;
|
||||
let format = numberFormats.get(key);
|
||||
if (!format) {
|
||||
format = new Intl.NumberFormat(settings.locale, {
|
||||
maximumFractionDigits: decimals,
|
||||
minimumFractionDigits: decimals,
|
||||
signDisplay: signed ? "exceptZero" : "auto",
|
||||
});
|
||||
numberFormats.set(key, format);
|
||||
}
|
||||
return format;
|
||||
}
|
||||
|
||||
export function toNumber(value: string | number | null | undefined): number {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return 0;
|
||||
}
|
||||
return typeof value === "number" ? value : Number.parseFloat(value);
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
value: string | number | null | undefined,
|
||||
decimals = 2,
|
||||
signed = false
|
||||
): string {
|
||||
if (settings.anonymous) {
|
||||
return numberFormat(decimals, false).format(0);
|
||||
}
|
||||
return numberFormat(decimals, signed).format(toNumber(value));
|
||||
}
|
||||
|
||||
export function formatMoney(money: Money, signed = false): string {
|
||||
const number = formatNumber(money.amount, money.decimals ?? 2, signed);
|
||||
const symbol = money.symbol ?? money.code ?? "";
|
||||
return symbol ? `${symbol}${number}` : number;
|
||||
}
|
||||
|
||||
export function compactMoney(value: number, symbol = ""): string {
|
||||
if (settings.anonymous) {
|
||||
return `${symbol}0`;
|
||||
}
|
||||
const format = new Intl.NumberFormat(settings.locale, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
});
|
||||
return `${symbol}${format.format(value)}`;
|
||||
}
|
||||
|
||||
const dateFormats = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function dateFormat(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||
const key = `${settings.locale}|${JSON.stringify(options)}`;
|
||||
let format = dateFormats.get(key);
|
||||
if (!format) {
|
||||
format = new Intl.DateTimeFormat(settings.locale, options);
|
||||
dateFormats.set(key, format);
|
||||
}
|
||||
return format;
|
||||
}
|
||||
|
||||
function toDate(value: string | Date): Date {
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
if (value.length === 10) {
|
||||
const [y, m, d] = value.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
export function formatDate(
|
||||
value: string | Date | null | undefined,
|
||||
style: "short" | "medium" | "long" | "day" | "month" | "year" = "medium"
|
||||
): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = toDate(value);
|
||||
switch (style) {
|
||||
case "short":
|
||||
return dateFormat({ day: "2-digit", month: "short" }).format(date);
|
||||
case "day":
|
||||
return dateFormat({
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
weekday: "short",
|
||||
}).format(date);
|
||||
case "month":
|
||||
return dateFormat({ month: "long", year: "numeric" }).format(date);
|
||||
case "year":
|
||||
return dateFormat({ year: "numeric" }).format(date);
|
||||
case "long":
|
||||
return dateFormat({ dateStyle: "long" }).format(date);
|
||||
default:
|
||||
return dateFormat({ dateStyle: "medium" }).format(date);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return dateFormat({ dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRange(start: string, end: string): string {
|
||||
return dateFormat({ dateStyle: "medium" }).formatRange(
|
||||
toDate(start),
|
||||
toDate(end)
|
||||
);
|
||||
}
|
||||
|
||||
export function percent(value: number, digits = 0): string {
|
||||
return new Intl.NumberFormat(settings.locale, {
|
||||
maximumFractionDigits: digits,
|
||||
style: "percent",
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
const SEPARATOR = /[\s/·-]/;
|
||||
|
||||
export function fuzzyScore(query: string, text: string): number {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) {
|
||||
return 1;
|
||||
}
|
||||
const t = text.toLowerCase();
|
||||
const direct = t.indexOf(q);
|
||||
if (direct !== -1) {
|
||||
const wordStart = direct === 0 || SEPARATOR.test(t[direct - 1]);
|
||||
return 1000 - direct + (wordStart ? 200 : 0) - t.length * 0.1;
|
||||
}
|
||||
let score = 0;
|
||||
let position = -1;
|
||||
let streak = 0;
|
||||
for (const char of q) {
|
||||
if (char === " ") {
|
||||
continue;
|
||||
}
|
||||
const next = t.indexOf(char, position + 1);
|
||||
if (next === -1) {
|
||||
return 0;
|
||||
}
|
||||
streak = next === position + 1 ? streak + 1 : 0;
|
||||
score += 10 + streak * 5 - (next - position) * 0.5;
|
||||
position = next;
|
||||
}
|
||||
return Math.max(score, 1);
|
||||
}
|
||||
|
||||
export function fuzzyFilter<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
text: (item: T) => string,
|
||||
limit = 50
|
||||
): T[] {
|
||||
if (!query.trim()) {
|
||||
return items.slice(0, limit);
|
||||
}
|
||||
return items
|
||||
.map((item) => ({ item, score: fuzzyScore(query, text(item)) }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export interface Binding {
|
||||
group?: string;
|
||||
hidden?: boolean;
|
||||
input?: boolean;
|
||||
keys: string;
|
||||
label: string;
|
||||
run: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
const SEQUENCE_MS = 1200;
|
||||
const EDITABLE =
|
||||
"input, textarea, select, [contenteditable=''], [contenteditable='true']";
|
||||
|
||||
function token(event: KeyboardEvent): string {
|
||||
const parts: string[] = [];
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
parts.push("mod");
|
||||
}
|
||||
if (event.altKey) {
|
||||
parts.push("alt");
|
||||
}
|
||||
const key = event.key === " " ? "Space" : event.key;
|
||||
if (event.shiftKey && key.length > 1) {
|
||||
parts.push("shift");
|
||||
}
|
||||
parts.push(key.length === 1 && parts.length > 0 ? key.toLowerCase() : key);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function isEditable(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && target.closest(EDITABLE) !== null;
|
||||
}
|
||||
|
||||
class Keys {
|
||||
layers = $state<Binding[][]>([]);
|
||||
pending = $state("");
|
||||
sheetOpen = $state(false);
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
all = $derived(this.layers.flat());
|
||||
|
||||
register(bindings: Binding[]): () => void {
|
||||
const layer = bindings;
|
||||
this.layers = [...this.layers, layer];
|
||||
return () => {
|
||||
this.layers = this.layers.filter((l) => l !== layer);
|
||||
};
|
||||
}
|
||||
|
||||
private find(combo: string, editable: boolean): Binding | undefined {
|
||||
for (let i = this.layers.length - 1; i >= 0; i -= 1) {
|
||||
const match = this.layers[i].find(
|
||||
(b) => b.keys === combo && (!editable || b.input)
|
||||
);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isPrefix(combo: string): boolean {
|
||||
return this.all.some((b) => b.keys.startsWith(`${combo} `));
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.pending = "";
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
handle = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (["Shift", "Control", "Meta", "Alt"].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
const editable = isEditable(event.target);
|
||||
const current = token(event);
|
||||
const combo = this.pending ? `${this.pending} ${current}` : current;
|
||||
const binding =
|
||||
this.find(combo, editable) ??
|
||||
(this.pending ? this.find(current, editable) : undefined);
|
||||
if (binding) {
|
||||
event.preventDefault();
|
||||
this.clear();
|
||||
binding.run(event);
|
||||
return;
|
||||
}
|
||||
if (!editable && this.isPrefix(current)) {
|
||||
event.preventDefault();
|
||||
this.clear();
|
||||
this.pending = current;
|
||||
this.timer = setTimeout(() => this.clear(), SEQUENCE_MS);
|
||||
return;
|
||||
}
|
||||
this.clear();
|
||||
};
|
||||
}
|
||||
|
||||
export const keys = new Keys();
|
||||
|
||||
export function useKeys(bindings: () => Binding[]) {
|
||||
$effect(() => keys.register(bindings()));
|
||||
}
|
||||
|
||||
const APPLE = /Mac|iPhone|iPad/;
|
||||
const PC_NAMES: Record<string, string> = {
|
||||
alt: "Alt",
|
||||
Enter: "↵",
|
||||
Escape: "Esc",
|
||||
mod: "Ctrl",
|
||||
shift: "Shift",
|
||||
};
|
||||
const MAC_NAMES: Record<string, string> = { ...PC_NAMES, alt: "⌥", mod: "⌘" };
|
||||
|
||||
export function prettyKeys(combo: string): string[] {
|
||||
const mac =
|
||||
typeof navigator !== "undefined" && APPLE.test(navigator.platform);
|
||||
return combo.split(" ").map((part) =>
|
||||
part
|
||||
.split("+")
|
||||
.map((piece) => (mac ? MAC_NAMES : PC_NAMES)[piece] ?? piece)
|
||||
.join(mac ? "" : "+")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||
|
||||
interface Options<T> {
|
||||
edit?: (item: T) => void;
|
||||
extra?: () => Binding[];
|
||||
id?: string;
|
||||
items: () => T[];
|
||||
key: (item: T) => string;
|
||||
open?: (item: T) => void;
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
export class ListNav<T> {
|
||||
cursor = $state(-1);
|
||||
selected = $state<Set<string>>(new Set());
|
||||
anchor = -1;
|
||||
private readonly options: Options<T>;
|
||||
|
||||
constructor(options: Options<T>) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this.options.items();
|
||||
}
|
||||
|
||||
get current(): T | undefined {
|
||||
return this.items[this.cursor];
|
||||
}
|
||||
|
||||
get selection(): T[] {
|
||||
return this.items.filter((item) =>
|
||||
this.selected.has(this.options.key(item))
|
||||
);
|
||||
}
|
||||
|
||||
get targets(): T[] {
|
||||
if (this.selected.size > 0) {
|
||||
return this.selection;
|
||||
}
|
||||
return this.current ? [this.current] : [];
|
||||
}
|
||||
|
||||
rowId(index: number) {
|
||||
return `${this.options.id ?? "list"}-row-${index}`;
|
||||
}
|
||||
|
||||
isSelected(item: T) {
|
||||
return this.selected.has(this.options.key(item));
|
||||
}
|
||||
|
||||
move(to: number) {
|
||||
const count = this.items.length;
|
||||
if (count === 0) {
|
||||
this.cursor = -1;
|
||||
return;
|
||||
}
|
||||
this.cursor = Math.max(0, Math.min(count - 1, to));
|
||||
document
|
||||
.getElementById(this.rowId(this.cursor))
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
toggle(item: T | undefined = this.current) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const key = this.options.key(item);
|
||||
const next = new Set(this.selected);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
this.selected = next;
|
||||
this.anchor = this.items.indexOf(item);
|
||||
}
|
||||
|
||||
extend(delta: number) {
|
||||
if (this.cursor < 0) {
|
||||
this.move(0);
|
||||
}
|
||||
const next = new Set(this.selected);
|
||||
if (this.current) {
|
||||
next.add(this.options.key(this.current));
|
||||
}
|
||||
this.move(this.cursor + delta);
|
||||
if (this.current) {
|
||||
next.add(this.options.key(this.current));
|
||||
}
|
||||
this.selected = next;
|
||||
}
|
||||
|
||||
selectRange(index: number) {
|
||||
const from = this.anchor < 0 ? index : this.anchor;
|
||||
const [a, b] = from < index ? [from, index] : [index, from];
|
||||
const next = new Set(this.selected);
|
||||
for (const item of this.items.slice(a, b + 1)) {
|
||||
next.add(this.options.key(item));
|
||||
}
|
||||
this.selected = next;
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
const allSelected = this.selected.size === this.items.length;
|
||||
this.selected = allSelected
|
||||
? new Set()
|
||||
: new Set(this.items.map((item) => this.options.key(item)));
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.selected = new Set();
|
||||
}
|
||||
|
||||
click(index: number, event: MouseEvent) {
|
||||
this.cursor = index;
|
||||
if (!this.options.selectable) {
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
this.selectRange(index);
|
||||
} else if (event.metaKey || event.ctrlKey) {
|
||||
this.toggle(this.items[index]);
|
||||
}
|
||||
}
|
||||
|
||||
bindings(): Binding[] {
|
||||
const list: Binding[] = [
|
||||
{
|
||||
group: "List",
|
||||
keys: "j",
|
||||
label: "Next row",
|
||||
run: () => this.move(this.cursor + 1),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
keys: "k",
|
||||
label: "Previous row",
|
||||
run: () => this.move(this.cursor - 1),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
hidden: true,
|
||||
keys: "ArrowDown",
|
||||
label: "Next row",
|
||||
run: () => this.move(this.cursor + 1),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
hidden: true,
|
||||
keys: "ArrowUp",
|
||||
label: "Previous row",
|
||||
run: () => this.move(this.cursor - 1),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
keys: "g g",
|
||||
label: "First row",
|
||||
run: () => this.move(0),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
keys: "G",
|
||||
label: "Last row",
|
||||
run: () => this.move(this.items.length - 1),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
hidden: true,
|
||||
keys: "mod+d",
|
||||
label: "Half page down",
|
||||
run: () => this.move(this.cursor + 15),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
hidden: true,
|
||||
keys: "mod+u",
|
||||
label: "Half page up",
|
||||
run: () => this.move(this.cursor - 15),
|
||||
},
|
||||
];
|
||||
const { open, edit, selectable } = this.options;
|
||||
if (open) {
|
||||
list.push(
|
||||
{
|
||||
group: "List",
|
||||
keys: "Enter",
|
||||
label: "Open row",
|
||||
run: () => this.current && open(this.current),
|
||||
},
|
||||
{
|
||||
group: "List",
|
||||
hidden: true,
|
||||
keys: "o",
|
||||
label: "Open row",
|
||||
run: () => this.current && open(this.current),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (edit) {
|
||||
list.push({
|
||||
group: "List",
|
||||
keys: "e",
|
||||
label: "Edit row",
|
||||
run: () => this.current && edit(this.current),
|
||||
});
|
||||
}
|
||||
if (selectable) {
|
||||
list.push(
|
||||
{
|
||||
group: "Selection",
|
||||
keys: "x",
|
||||
label: "Select row",
|
||||
run: () => this.toggle(),
|
||||
},
|
||||
{
|
||||
group: "Selection",
|
||||
hidden: true,
|
||||
keys: "Space",
|
||||
label: "Select row",
|
||||
run: () => this.toggle(),
|
||||
},
|
||||
{
|
||||
group: "Selection",
|
||||
keys: "J",
|
||||
label: "Extend selection down",
|
||||
run: () => this.extend(1),
|
||||
},
|
||||
{
|
||||
group: "Selection",
|
||||
keys: "K",
|
||||
label: "Extend selection up",
|
||||
run: () => this.extend(-1),
|
||||
},
|
||||
{
|
||||
group: "Selection",
|
||||
keys: "*",
|
||||
label: "Select all / none",
|
||||
run: () => this.selectAll(),
|
||||
},
|
||||
{
|
||||
group: "Selection",
|
||||
keys: "u",
|
||||
label: "Clear selection",
|
||||
run: () => this.clear(),
|
||||
}
|
||||
);
|
||||
}
|
||||
return [...list, ...(this.options.extra?.() ?? [])];
|
||||
}
|
||||
|
||||
use() {
|
||||
useKeys(() => this.bindings());
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
|
||||
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
|
||||
import ChartColumn from "@lucide/svelte/icons/chart-column";
|
||||
import ChartPie from "@lucide/svelte/icons/chart-pie";
|
||||
import Cpu from "@lucide/svelte/icons/cpu";
|
||||
import CreditCard from "@lucide/svelte/icons/credit-card";
|
||||
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
|
||||
import PiggyBank from "@lucide/svelte/icons/piggy-bank";
|
||||
import SlidersHorizontal from "@lucide/svelte/icons/sliders-horizontal";
|
||||
import Tags from "@lucide/svelte/icons/tags";
|
||||
import Upload from "@lucide/svelte/icons/upload";
|
||||
import type { Component } from "svelte";
|
||||
|
||||
export interface NavLink {
|
||||
href: string;
|
||||
keys?: string;
|
||||
label: string;
|
||||
match?: string[];
|
||||
old?: boolean;
|
||||
}
|
||||
|
||||
export type NavItem = NavLink & {
|
||||
icon: Component;
|
||||
children?: NavLink[];
|
||||
};
|
||||
|
||||
export interface NavSection {
|
||||
items: NavItem[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const NAV: NavSection[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
href: "/",
|
||||
icon: LayoutDashboard,
|
||||
keys: "g d",
|
||||
label: "Dashboard",
|
||||
match: ["/", "/home"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ href: "/budgets", icon: ChartPie, keys: "g b", label: "Budgets" },
|
||||
{
|
||||
href: "/subscriptions",
|
||||
icon: CalendarClock,
|
||||
keys: "g s",
|
||||
label: "Subscriptions",
|
||||
match: ["/subscriptions", "/bills"],
|
||||
},
|
||||
{
|
||||
href: "/piggy-banks",
|
||||
icon: PiggyBank,
|
||||
keys: "g p",
|
||||
label: "Piggy banks",
|
||||
},
|
||||
],
|
||||
title: "Financial control",
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
href: "/transactions/all",
|
||||
keys: "g t",
|
||||
label: "All transactions",
|
||||
},
|
||||
{
|
||||
href: "/transactions/withdrawal",
|
||||
keys: "g w",
|
||||
label: "Expenses",
|
||||
},
|
||||
{
|
||||
href: "/transactions/deposit",
|
||||
keys: "g i",
|
||||
label: "Revenue / income",
|
||||
},
|
||||
{
|
||||
href: "/transactions/transfers",
|
||||
keys: "g x",
|
||||
label: "Transfers",
|
||||
match: ["/transactions/transfers", "/transactions/transfer"],
|
||||
},
|
||||
],
|
||||
href: "/transactions/all",
|
||||
icon: ArrowLeftRight,
|
||||
label: "Transactions",
|
||||
match: ["/transactions"],
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{ href: "/rules", label: "Rules", match: ["/rules", "/rule-groups"] },
|
||||
{ href: "/recurring", label: "Recurring transactions" },
|
||||
{ href: "/webhooks/index", label: "Webhooks", match: ["/webhooks"] },
|
||||
],
|
||||
href: "/rules",
|
||||
icon: Cpu,
|
||||
label: "Automation",
|
||||
match: ["/rules", "/rule-groups", "/recurring", "/webhooks"],
|
||||
},
|
||||
],
|
||||
title: "Accounting",
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
children: [
|
||||
{ href: "/accounts/asset", keys: "g a", label: "Asset accounts" },
|
||||
{ href: "/accounts/expense", keys: "g e", label: "Expense accounts" },
|
||||
{ href: "/accounts/revenue", keys: "g v", label: "Revenue accounts" },
|
||||
{ href: "/accounts/liabilities", keys: "g l", label: "Liabilities" },
|
||||
],
|
||||
href: "/accounts/asset",
|
||||
icon: CreditCard,
|
||||
label: "Accounts",
|
||||
match: ["/accounts", "/inactive-accounts"],
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{ href: "/categories", keys: "g c", label: "Categories" },
|
||||
{ href: "/tags", keys: "g m", label: "Tags" },
|
||||
{ href: "/groups", keys: "g o", label: "Groups" },
|
||||
],
|
||||
href: "/categories",
|
||||
icon: Tags,
|
||||
label: "Classification",
|
||||
match: ["/categories", "/tags", "/groups"],
|
||||
},
|
||||
{ href: "/reports", icon: ChartColumn, keys: "g r", label: "Reports" },
|
||||
{ href: "/export", icon: Upload, label: "Export data" },
|
||||
{
|
||||
children: [
|
||||
{ href: "/profile", label: "Profile", old: true },
|
||||
{
|
||||
href: "/profile/oauth",
|
||||
label: "Remote access and tokens",
|
||||
old: true,
|
||||
},
|
||||
{ href: "/preferences", keys: "g ,", label: "Preferences" },
|
||||
{ href: "/currencies", label: "Currencies" },
|
||||
{ href: "/exchange-rates", label: "Exchange rates" },
|
||||
{ href: "/administrations", label: "Financial administrations" },
|
||||
{ href: "/settings", label: "System settings", old: true },
|
||||
],
|
||||
href: "/preferences",
|
||||
icon: SlidersHorizontal,
|
||||
label: "Options",
|
||||
match: [
|
||||
"/profile",
|
||||
"/preferences",
|
||||
"/currencies",
|
||||
"/exchange-rates",
|
||||
"/administrations",
|
||||
"/settings",
|
||||
],
|
||||
},
|
||||
],
|
||||
title: "Others",
|
||||
},
|
||||
];
|
||||
|
||||
export const CREATE_LINKS: NavLink[] = [
|
||||
{ href: "/transactions/create/withdrawal", label: "New withdrawal" },
|
||||
{ href: "/transactions/create/deposit", label: "New deposit" },
|
||||
{ href: "/transactions/create/transfer", label: "New transfer" },
|
||||
{ href: "/accounts/create/asset", label: "New asset account" },
|
||||
{ href: "/accounts/create/expense", label: "New expense account" },
|
||||
{ href: "/accounts/create/revenue", label: "New revenue account" },
|
||||
{ href: "/accounts/create/liabilities", label: "New liability" },
|
||||
{ href: "/budgets/create", label: "New budget" },
|
||||
{ href: "/categories/create", label: "New category" },
|
||||
{ href: "/piggy-banks/create", label: "New piggy bank" },
|
||||
{ href: "/subscriptions/create", label: "New subscription" },
|
||||
{ href: "/rules/create", label: "New rule", old: true },
|
||||
{ href: "/recurring/create", label: "New recurring transaction", old: true },
|
||||
];
|
||||
|
||||
export function allLinks(): NavLink[] {
|
||||
return NAV.flatMap((section) =>
|
||||
section.items.flatMap((item) =>
|
||||
item.children ? item.children : [item as NavLink]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isActive(link: NavLink, pathname: string): boolean {
|
||||
const prefixes = link.match ?? [link.href];
|
||||
return prefixes.some((prefix) =>
|
||||
prefix === "/"
|
||||
? pathname === "/"
|
||||
: pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
addDays,
|
||||
isDynamic,
|
||||
periodOf,
|
||||
type Range,
|
||||
shiftPeriod,
|
||||
today,
|
||||
type ViewRange,
|
||||
} from "#lib/dates.js";
|
||||
import { formatDate, formatRange } from "#lib/format.js";
|
||||
|
||||
const STORAGE_KEY = "ffui.period";
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export interface Preset {
|
||||
label: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
function stored(): Range | null {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
|
||||
return ISO_DATE.test(value?.start) && ISO_DATE.test(value?.end)
|
||||
? value
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rangeFromUrl(url: {
|
||||
searchParams: { get: (name: string) => string | null };
|
||||
}): Range | null {
|
||||
const start = url.searchParams.get("start") ?? "";
|
||||
const end = url.searchParams.get("end") ?? "";
|
||||
return ISO_DATE.test(start) && ISO_DATE.test(end) && start <= end
|
||||
? { end, start }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function periodLabel(range: Range, view: ViewRange): string {
|
||||
const aligned = periodOf(range.start, isDynamic(view) ? "1M" : view);
|
||||
if (aligned.start === range.start && aligned.end === range.end) {
|
||||
if (view === "1M" || isDynamic(view)) {
|
||||
return formatDate(range.start, "month");
|
||||
}
|
||||
if (view === "1Y") {
|
||||
return formatDate(range.start, "year");
|
||||
}
|
||||
}
|
||||
return formatRange(range.start, range.end);
|
||||
}
|
||||
|
||||
class Period {
|
||||
view = $state<ViewRange>("1M");
|
||||
start = $state(today());
|
||||
end = $state(today());
|
||||
first = $state<string | null>(null);
|
||||
ready = $state(false);
|
||||
|
||||
range = $derived<Range>({ end: this.end, start: this.start });
|
||||
label = $derived(periodLabel(this.range, this.view));
|
||||
isCurrent = $derived(this.start <= today() && today() <= this.end);
|
||||
|
||||
init(view: ViewRange, url: Parameters<typeof rangeFromUrl>[0]) {
|
||||
this.view = view;
|
||||
const range = rangeFromUrl(url) ?? stored() ?? periodOf(today(), view);
|
||||
this.start = range.start;
|
||||
this.end = range.end;
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
set(range: Range) {
|
||||
if (range.start === this.start && range.end === this.end) {
|
||||
return;
|
||||
}
|
||||
this.start = range.start;
|
||||
this.end = range.end;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(range));
|
||||
}
|
||||
|
||||
shift(dir: 1 | -1) {
|
||||
this.set(shiftPeriod(this.range, this.view, dir));
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.set(periodOf(today(), this.view));
|
||||
}
|
||||
|
||||
presets(): Preset[] {
|
||||
const now = today();
|
||||
const { view } = this;
|
||||
const presets: Preset[] = [];
|
||||
if (!isDynamic(view)) {
|
||||
const prev = shiftPeriod(this.range, view, -1);
|
||||
const next = shiftPeriod(this.range, view, 1);
|
||||
presets.push({ label: periodLabel(prev, view), range: prev });
|
||||
presets.push({ label: periodLabel(next, view), range: next });
|
||||
}
|
||||
const current = periodOf(now, view);
|
||||
presets.push({
|
||||
label: `Today (${periodLabel(current, view)})`,
|
||||
range: current,
|
||||
});
|
||||
presets.push({
|
||||
label: "Last seven days",
|
||||
range: { end: now, start: addDays(now, -7) },
|
||||
});
|
||||
presets.push({
|
||||
label: "Last thirty days",
|
||||
range: { end: now, start: addDays(now, -30) },
|
||||
});
|
||||
presets.push({ label: "Month to date", range: periodOf(now, "MTD") });
|
||||
presets.push({ label: "Year to date", range: periodOf(now, "YTD") });
|
||||
const year = Number(now.slice(0, 4)) - 1;
|
||||
presets.push({
|
||||
label: `Previous year (${year})`,
|
||||
range: { end: `${year}-12-31`, start: `${year}-01-01` },
|
||||
});
|
||||
if (this.first) {
|
||||
presets.push({
|
||||
label: "Everything",
|
||||
range: { end: now, start: this.first },
|
||||
});
|
||||
}
|
||||
return presets;
|
||||
}
|
||||
}
|
||||
|
||||
export const period = new Period();
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
api,
|
||||
keepAlive,
|
||||
leaveTo,
|
||||
ping,
|
||||
takeReturnPath,
|
||||
} from "#lib/api/client.js";
|
||||
import type {
|
||||
Currency,
|
||||
Item,
|
||||
Page,
|
||||
Preference,
|
||||
SystemInfo,
|
||||
} from "#lib/api/types.js";
|
||||
import type { ViewRange } from "#lib/dates.js";
|
||||
import { configureFormat } from "#lib/format.js";
|
||||
|
||||
export const TESTED_VERSION = "6.7.9";
|
||||
|
||||
type About = SystemInfo;
|
||||
interface User {
|
||||
data: { attributes: { email: string } };
|
||||
}
|
||||
|
||||
function toLocale(locale: unknown, language: unknown): string {
|
||||
const raw = locale && locale !== "equal" ? locale : language;
|
||||
return typeof raw === "string" && raw ? raw.replace("_", "-") : "en-GB";
|
||||
}
|
||||
|
||||
const VERSION_PREFIX = /^v/;
|
||||
const VERSION_SPLIT = /[.-]/;
|
||||
|
||||
const versionParts = (value: string) =>
|
||||
value.replace(VERSION_PREFIX, "").split(VERSION_SPLIT).map(Number);
|
||||
|
||||
export function newer(a: string, b: string): boolean {
|
||||
const pa = versionParts(a);
|
||||
const pb = versionParts(b);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
||||
const diff = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (diff !== 0) {
|
||||
return diff > 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class Session {
|
||||
ready = $state(false);
|
||||
failed = $state("");
|
||||
version = $state("");
|
||||
apiVersion = $state("");
|
||||
email = $state("");
|
||||
prefs = $state<Record<string, unknown>>({});
|
||||
currencies = $state<Item<Currency>[]>([]);
|
||||
|
||||
locale = $derived(toLocale(this.prefs.locale, this.prefs.language));
|
||||
viewRange = $derived((this.prefs.viewRange as ViewRange) ?? "1M");
|
||||
pageSize = $derived(Number(this.prefs.listPageSize) || 50);
|
||||
anonymous = $derived(this.prefs.anonymous === true);
|
||||
frontpageAccounts = $derived(
|
||||
((this.prefs.frontpageAccounts ?? []) as (number | string)[]).map(String)
|
||||
);
|
||||
primary = $derived(
|
||||
this.currencies.find((c) => c.attributes.primary)?.attributes ??
|
||||
this.currencies[0]?.attributes
|
||||
);
|
||||
drift = $derived(
|
||||
this.apiVersion !== "" && newer(this.apiVersion, TESTED_VERSION)
|
||||
);
|
||||
|
||||
async loadPreferences() {
|
||||
const page = await api<Page<Preference>>("/v1/preferences", {
|
||||
query: { limit: 500 },
|
||||
});
|
||||
const prefs: Record<string, unknown> = {};
|
||||
for (const item of page.data) {
|
||||
prefs[item.attributes.name] = item.attributes.data;
|
||||
}
|
||||
this.prefs = prefs;
|
||||
configureFormat(this.locale, this.anonymous);
|
||||
}
|
||||
|
||||
async loadCurrencies() {
|
||||
const page = await api<Page<Currency>>("/v1/currencies", {
|
||||
query: { limit: 500 },
|
||||
});
|
||||
this.currencies = page.data;
|
||||
}
|
||||
|
||||
async setPreference(name: string, data: unknown) {
|
||||
const exists = name in this.prefs;
|
||||
await api(exists ? `/v1/preferences/${name}` : "/v1/preferences", {
|
||||
body: exists ? { data } : { data, name },
|
||||
method: exists ? "PUT" : "POST",
|
||||
});
|
||||
this.prefs = { ...this.prefs, [name]: data };
|
||||
configureFormat(this.locale, this.anonymous);
|
||||
}
|
||||
|
||||
async boot(): Promise<string | null> {
|
||||
const state = await ping();
|
||||
if (state !== "ok") {
|
||||
leaveTo(state);
|
||||
return null;
|
||||
}
|
||||
const [about, user] = await Promise.all([
|
||||
api<About>("/v1/about"),
|
||||
api<User>("/v1/about/user"),
|
||||
this.loadPreferences(),
|
||||
this.loadCurrencies(),
|
||||
]);
|
||||
this.version = about.data?.version ?? "";
|
||||
this.apiVersion = about.data?.api_version ?? "";
|
||||
this.email = user.data.attributes.email;
|
||||
this.ready = true;
|
||||
keepAlive();
|
||||
return takeReturnPath();
|
||||
}
|
||||
}
|
||||
|
||||
export const session = new Session();
|
||||
@@ -0,0 +1,75 @@
|
||||
export const SPA_EXACT = [
|
||||
"/",
|
||||
"/home",
|
||||
"/accounts",
|
||||
"/budgets",
|
||||
"/categories",
|
||||
"/tags",
|
||||
"/bills",
|
||||
"/subscriptions",
|
||||
"/piggy-banks",
|
||||
"/groups",
|
||||
"/reports",
|
||||
"/search",
|
||||
"/currencies",
|
||||
"/exchange-rates",
|
||||
"/attachments",
|
||||
"/preferences",
|
||||
"/export",
|
||||
"/administrations",
|
||||
"/recurring",
|
||||
"/webhooks/index",
|
||||
"/settings/links",
|
||||
];
|
||||
|
||||
export const SPA_PREFIXES = [
|
||||
"/accounts/",
|
||||
"/inactive-accounts/",
|
||||
"/transactions/",
|
||||
"/budgets/",
|
||||
"/categories/",
|
||||
"/tags/",
|
||||
"/bills/",
|
||||
"/subscriptions/",
|
||||
"/piggy-banks/",
|
||||
"/groups/",
|
||||
"/reports/",
|
||||
"/currencies/",
|
||||
"/exchange-rates/",
|
||||
"/attachments/",
|
||||
"/administrations/",
|
||||
"/recurring/show/",
|
||||
"/webhooks/show/",
|
||||
"/settings/links/show/",
|
||||
];
|
||||
|
||||
export const FIREFLY_PREFIXES = [
|
||||
"/accounts/reconcile/",
|
||||
"/attachments/download/",
|
||||
"/attachments/view/",
|
||||
"/transactions/convert/",
|
||||
"/transactions/debug/",
|
||||
"/transactions/link/",
|
||||
"/transactions/mass/",
|
||||
"/transactions/bulk/",
|
||||
"/transactions/show-by-journal/",
|
||||
"/piggy-banks/add/",
|
||||
"/piggy-banks/remove/",
|
||||
"/reports/options/",
|
||||
];
|
||||
|
||||
export function isSpaPath(pathname: string): boolean {
|
||||
if (FIREFLY_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
SPA_EXACT.includes(pathname) ||
|
||||
SPA_PREFIXES.some((prefix) => pathname.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
|
||||
export function oldUiUrl(href: string, sticky = false): string {
|
||||
const target = new URL(href);
|
||||
target.searchParams.set("ffui", sticky ? "off" : "old");
|
||||
return target.pathname + target.search + target.hash;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const SIDEBAR_KEY = "ffui.sidebar.collapsed";
|
||||
|
||||
class Ui {
|
||||
palette = $state(false);
|
||||
paletteQuery = $state("");
|
||||
quickEntry = $state(false);
|
||||
mobileNav = $state(false);
|
||||
collapsed = $state(
|
||||
typeof localStorage !== "undefined" &&
|
||||
localStorage.getItem(SIDEBAR_KEY) === "1"
|
||||
);
|
||||
|
||||
openPalette(query = "") {
|
||||
this.paletteQuery = query;
|
||||
this.palette = true;
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
if (window.matchMedia("(max-width: 1023px)").matches) {
|
||||
this.mobileNav = !this.mobileNav;
|
||||
return;
|
||||
}
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem(SIDEBAR_KEY, this.collapsed ? "1" : "0");
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = new Ui();
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineParams } from "@sveltejs/kit/params";
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const ID = /^\d+$/;
|
||||
const ACCOUNT_TYPES = new Set(["asset", "expense", "revenue", "liabilities"]);
|
||||
const TRANSACTION_TYPES = new Set([
|
||||
"withdrawal",
|
||||
"deposit",
|
||||
"transfers",
|
||||
"transfer",
|
||||
"all",
|
||||
]);
|
||||
|
||||
export const params = defineParams({
|
||||
accounttype: (value) => (ACCOUNT_TYPES.has(value) ? value : undefined),
|
||||
date: (value) => (ISO_DATE.test(value) ? value : undefined),
|
||||
id: (value) => (ID.test(value) ? value : undefined),
|
||||
txtype: (value) => (TRANSACTION_TYPES.has(value) ? value : undefined),
|
||||
});
|
||||
@@ -1,10 +1,93 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import { QueryClientProvider } from "@tanstack/svelte-query";
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { queryClient } from "#lib/api/lists.js";
|
||||
import QuickEntry from "#lib/components/QuickEntry.svelte";
|
||||
import Shell from "#lib/components/shell/Shell.svelte";
|
||||
import { keys } from "#lib/keys.svelte.js";
|
||||
import { period, rangeFromUrl } from "#lib/period.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let lastHref = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const back = await session.boot();
|
||||
period.init(session.viewRange, page.url);
|
||||
if (back) {
|
||||
await goto(back, { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
session.failed = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!period.ready) {
|
||||
return;
|
||||
}
|
||||
const url = page.url;
|
||||
const start = period.start;
|
||||
const end = period.end;
|
||||
untrack(() => {
|
||||
const fromUrl = rangeFromUrl(url);
|
||||
if (url.href !== lastHref && fromUrl) {
|
||||
lastHref = url.href;
|
||||
period.set(fromUrl);
|
||||
return;
|
||||
}
|
||||
lastHref = url.href;
|
||||
if (fromUrl?.start === start && fromUrl?.end === end) {
|
||||
return;
|
||||
}
|
||||
const next = new URL(url.href);
|
||||
next.searchParams.set("start", start);
|
||||
next.searchParams.set("end", end);
|
||||
lastHref = next.href;
|
||||
goto(next, { replace: true, shallow: true, state: page.state });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
<svelte:window onkeydown={keys.handle} />
|
||||
|
||||
{@render children()}
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{#if session.ready}
|
||||
<Shell>
|
||||
{@render children()}
|
||||
{#snippet quick()}
|
||||
<QuickEntry />
|
||||
{/snippet}
|
||||
</Shell>
|
||||
{:else if session.failed}
|
||||
<main class="grid min-h-dvh place-items-center p-6 text-center">
|
||||
<div class="flex max-w-md flex-col gap-3">
|
||||
<h1 class="font-semibold text-[18px]">Firefly III did not answer</h1>
|
||||
<p class="text-cladd-fg-soft">{session.failed}</p>
|
||||
<p class="flex justify-center gap-4">
|
||||
<a class="text-brand underline" href={page.url.pathname}>Try again</a>
|
||||
<a class="text-brand underline" data-sveltekit-reload href="/login"
|
||||
>Log in</a
|
||||
>
|
||||
<a
|
||||
class="text-brand underline"
|
||||
data-sveltekit-reload
|
||||
href={`${page.url.pathname}?ffui=old`}
|
||||
>Open the old interface</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="grid min-h-dvh place-items-center text-cladd-fg-soft">
|
||||
<span>Connecting to Firefly III</span>
|
||||
</main>
|
||||
{/if}
|
||||
</QueryClientProvider>
|
||||
+4
-12
@@ -1,13 +1,5 @@
|
||||
<svelte:head>
|
||||
<title>Firefly UI</title>
|
||||
</svelte:head>
|
||||
<script lang="ts">
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
</script>
|
||||
|
||||
<main
|
||||
class="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center bg-background text-foreground"
|
||||
>
|
||||
<h1 class="font-bold text-4xl tracking-tight sm:text-5xl">Firefly UI</h1>
|
||||
<p class="max-w-prose text-base text-muted-foreground">
|
||||
Edit
|
||||
<code class="font-mono">src/routes/+page.svelte</code>.
|
||||
</p>
|
||||
</main>
|
||||
<PageHeader subtitle="What's playing?" title="Dashboard" />
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export function load() {
|
||||
redirect(307, "/");
|
||||
}
|
||||
@@ -5,11 +5,111 @@
|
||||
@import "./claddcn.css";
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--ff-brand: oklch(0.57 0.105 240);
|
||||
--ff-brand-deep: oklch(0.5 0.1 240);
|
||||
--ff-on-brand: oklch(0.99 0.01 240);
|
||||
--ff-income: oklch(0.5 0.12 145);
|
||||
--ff-expense: oklch(0.52 0.15 28);
|
||||
--ff-transfer: oklch(0.5 0.085 225);
|
||||
--ff-warn: oklch(0.62 0.14 70);
|
||||
--ff-row-hover: oklch(0.96 0.012 240);
|
||||
--ff-row-cursor: oklch(0.93 0.03 240);
|
||||
--ff-row-selected: oklch(0.9 0.05 240);
|
||||
--ff-series-1: oklch(0.57 0.105 240);
|
||||
--ff-series-2: oklch(0.62 0.13 160);
|
||||
--ff-series-3: oklch(0.7 0.14 70);
|
||||
--ff-series-4: oklch(0.58 0.15 20);
|
||||
--ff-series-5: oklch(0.55 0.12 300);
|
||||
--ff-series-6: oklch(0.65 0.1 200);
|
||||
--ff-series-7: oklch(0.6 0.13 110);
|
||||
--ff-series-8: oklch(0.6 0.08 260);
|
||||
}
|
||||
|
||||
.cladd-color-brand {
|
||||
--cladd-theme: #3c8dbc;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ff-brand: oklch(0.42 0.085 240);
|
||||
--ff-brand-deep: oklch(0.36 0.075 240);
|
||||
--ff-on-brand: oklch(0.97 0.01 240);
|
||||
--ff-income: oklch(0.78 0.15 140);
|
||||
--ff-expense: oklch(0.76 0.1 35);
|
||||
--ff-transfer: oklch(0.76 0.08 220);
|
||||
--ff-warn: oklch(0.8 0.13 80);
|
||||
--ff-row-hover: oklch(0.25 0.012 240);
|
||||
--ff-row-cursor: oklch(0.29 0.03 240);
|
||||
--ff-row-selected: oklch(0.33 0.06 240);
|
||||
--ff-series-1: oklch(0.7 0.1 240);
|
||||
--ff-series-2: oklch(0.72 0.12 160);
|
||||
--ff-series-3: oklch(0.8 0.13 75);
|
||||
--ff-series-4: oklch(0.7 0.13 25);
|
||||
--ff-series-5: oklch(0.7 0.11 300);
|
||||
--ff-series-6: oklch(0.75 0.09 200);
|
||||
--ff-series-7: oklch(0.75 0.12 115);
|
||||
--ff-series-8: oklch(0.72 0.07 260);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-brand: var(--ff-brand);
|
||||
--color-brand-deep: var(--ff-brand-deep);
|
||||
--color-on-brand: var(--ff-on-brand);
|
||||
--color-income: var(--ff-income);
|
||||
--color-expense: var(--ff-expense);
|
||||
--color-transfer: var(--ff-transfer);
|
||||
--color-warn: var(--ff-warn);
|
||||
--color-row-hover: var(--ff-row-hover);
|
||||
--color-row-cursor: var(--ff-row-cursor);
|
||||
--color-row-selected: var(--ff-row-selected);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
scrollbar-color: var(--cladd-fg-softest) transparent;
|
||||
}
|
||||
body {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
::selection {
|
||||
background: color-mix(in oklch, var(--ff-brand) 28%, transparent);
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 1.5px solid var(--ff-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.dark :focus-visible {
|
||||
outline-color: var(--ff-series-1);
|
||||
}
|
||||
a {
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@utility kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.35rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--cladd-fg-soft);
|
||||
background: var(--cladd-surface-cut);
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 -1px 0 var(--cladd-outline);
|
||||
}
|
||||
Reference in new issue
Block a user