feat(shell,api,keys): firefly v1 shell over the api with cookie auth, period, palette and keymap

This commit is contained in:
hh committed 2026-10-16 02:21:45 +00:00
1 parent ae4de7911b
commit c74dac3e00
37 files changed
+58163 -79

No files matched your search

+202 -42
View File
@@ -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),
};
+80
View File
@@ -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();
}
+28401
View File
File diff suppressed because it is too large. Load diff
+65
View File
@@ -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";
+38
View File
@@ -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
>
+54
View File
@@ -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>
+61
View File
@@ -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>
+9
View File
@@ -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>
+291
View File
@@ -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>
+322
View File
@@ -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 />
+150
View File
@@ -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>
+150
View File
@@ -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;
}
+143
View File
@@ -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);
}
+47
View File
@@ -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);
}
+125
View File
@@ -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 ? "" : "+")
);
}
+255
View File
@@ -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
View File
@@ -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}/`)
);
}
+130
View File
@@ -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();
+122
View File
@@ -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();
+75
View File
@@ -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;
}
+28
View File
@@ -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();