feat(currencies,preferences,export,misc): currencies, rates, attachments, link types, preferences, export, administrations, recurring and webhooks

This commit is contained in:
hh committed 2026-10-16 03:42:24 +00:00
1 parent 89bbf56062
commit f8a5fd6c6d
47 files changed
+4496 -8

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
label,
for: target,
problem,
help,
children,
}: {
label: string;
for?: string;
problem?: string;
help?: string;
children: Snippet;
} = $props();
</script>
<div class="@container">
<div class="grid gap-1 @lg:grid-cols-[11rem_minmax(0,1fr)] @lg:gap-3">
<label
class="font-medium text-[13px] text-cladd-fg @lg:pt-1.5 @lg:text-right"
for={target}
>{label}</label
>
<div class="flex min-w-0 flex-col gap-1">
{@render children()}
{#if problem}
<p class="text-[12px] text-expense">{problem}</p>
{:else if help}
<p class="text-[12px] text-cladd-fg-soft">{help}</p>
{/if}
</div>
</div>
</div>
+76
View File
@@ -0,0 +1,76 @@
<script generics="T" lang="ts">
import { ContextMenu } from "bits-ui";
import type { Snippet } from "svelte";
import ContextMenuContent from "#lib/components/ui/context-menu/context-menu-content.svelte";
import type { ListNav } from "#lib/list-nav.svelte.js";
let {
nav,
key,
head,
row,
menu,
empty = "Nothing here yet",
class: className = "",
}: {
nav: ListNav<T>;
key: (item: T) => string;
head: Snippet;
row: Snippet<[T, number]>;
menu?: Snippet<[T]>;
empty?: string;
class?: string;
} = $props();
</script>
{#snippet rows()}
{#each nav.items as item, i (key(item))}
<tr
class="h-7 border-cladd-outline/60 border-b last:border-b-0 {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
oncontextmenu={() => (nav.cursor = i)}
>
{@render row(item, i)}
</tr>
{:else}
<tr>
<td class="px-2 py-3 text-cladd-fg-soft" colspan="99">{empty}</td>
</tr>
{/each}
{/snippet}
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px] {className}">
<thead>
<tr>
{@render head()}
</tr>
</thead>
{#if menu}
<ContextMenu.Root>
<ContextMenu.Trigger>
{#snippet child({
props,
})}
<tbody {...props}>
{@render rows()}
</tbody>
{/snippet}
</ContextMenu.Trigger>
<ContextMenuContent class="w-56">
{#if nav.current}
{@render menu(nav.current)}
{/if}
</ContextMenuContent>
</ContextMenu.Root>
{:else}
<tbody>
{@render rows()}
</tbody>
{/if}
</table>
</div>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import type { Arm } from "./action.svelte.js";
let {
arm,
id,
run,
label = "Delete",
}: { arm: Arm; id: string; run: () => unknown; label?: string } = $props();
const armed = $derived(arm.key === id);
</script>
<ContextMenuItem
onSelect={(event) => {
if (!armed) {
event.preventDefault();
}
arm.press(id, run);
}}
variant="destructive"
>
{armed ? "Press again to delete" : label}
<span class="ml-auto kbd">D</span>
</ContextMenuItem>
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
href,
label = "Open the old interface",
children,
}: { href: string; label?: string; children: Snippet } = $props();
</script>
<p class="text-[12px] text-cladd-fg-soft">
{@render children()}
<a class="text-brand hover:underline" data-sveltekit-reload {href}>{label}</a>
</p>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Action } from "./action.svelte.js";
let { action, busy = "Saving" }: { action: Action; busy?: string } = $props();
</script>
{#if action.busy}
<span class="text-[12px] text-cladd-fg-soft">{busy}</span>
{:else if action.message}
<span
class="text-[12px] {action.failed ? "text-expense" : "text-income"}"
role="status"
>{action.message}</span
>
{/if}
+66
View File
@@ -0,0 +1,66 @@
import { ApiError } from "#lib/api/client.js";
const UNAUTHORIZED = 401;
const ARM_MS = 3000;
export function errorText(error: unknown): string {
if (error instanceof ApiError) {
if (error.status === UNAUTHORIZED) {
return "Only the owner can do this";
}
return Object.values(error.errors)[0]?.[0] ?? error.message;
}
return error instanceof Error ? error.message : String(error);
}
export class Action {
busy = $state(false);
message = $state("");
failed = $state(false);
errors = $state<Record<string, string[]>>({});
async run(task: () => Promise<unknown>, done = "Saved"): Promise<boolean> {
this.busy = true;
this.message = "";
this.failed = false;
this.errors = {};
try {
await task();
this.message = done;
return true;
} catch (error) {
this.failed = true;
this.message = errorText(error);
this.errors = error instanceof ApiError ? error.errors : {};
return false;
} finally {
this.busy = false;
}
}
field(name: string): string | undefined {
return this.errors[name]?.[0];
}
reset() {
this.message = "";
this.failed = false;
}
}
export class Arm {
key = $state<string | null>(null);
private timer: ReturnType<typeof setTimeout> | undefined;
press(key: string, run: () => unknown) {
clearTimeout(this.timer);
if (this.key === key) {
this.key = null;
return run();
}
this.key = key;
this.timer = setTimeout(() => {
this.key = null;
}, ARM_MS);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { ApiError, apiHeaders, apiUrl, type Query } from "#lib/api/client.js";
const FILENAME = /filename="?([^";]+)"?/;
const REVOKE_MS = 1000;
export async function download(
path: string,
query: Query,
fallback: string
): Promise<number> {
const response = await fetch(apiUrl(path, query), {
credentials: "same-origin",
headers: apiHeaders(false),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
throw new ApiError(response.status, body?.message ?? response.statusText);
}
const blob = await response.blob();
const name =
response.headers.get("Content-Disposition")?.match(FILENAME)?.[1] ??
fallback;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), REVOKE_MS);
return blob.size;
}
+45
View File
@@ -0,0 +1,45 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Currency, One } from "#lib/api/types.js";
interface Me {
email: string;
role?: string | null;
}
const FOREVER = Number.POSITIVE_INFINITY;
export const misc = {
config: (key: string) => ({
queryFn: () =>
api<{ data: { value: unknown } }>(`/v1/configuration/${key}`).then(
(body) => body.data.value
),
queryKey: ["configuration", key],
staleTime: FOREVER,
}),
currencies: () => ({
queryFn: () => all<Currency>("/v1/currencies"),
queryKey: ["currencies"],
}),
me: () => ({
queryFn: () =>
api<One<Me>>("/v1/about/user").then((body) => body.data.attributes),
queryKey: ["about-user"],
staleTime: FOREVER,
}),
};
export const dateOnly = (value?: string | null) => value?.slice(0, 10);
export function sizeText(bytes: number | null | undefined): string {
const value = bytes ?? 0;
const units = ["B", "KB", "MB", "GB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
}
+11
View File
@@ -0,0 +1,11 @@
export const th =
"h-8 whitespace-nowrap border-cladd-outline border-b px-2 text-left font-semibold text-[12px] text-cladd-fg-soft";
export const thNum = `${th} text-right`;
export const td = "px-2 py-1 align-middle";
export const tdNum = `${td} num text-right`;
export const link = "text-brand hover:underline";
export const select =
"cladd-cut cladd-hoverable cladd-focusable-input h-cladd-md w-full min-w-0 rounded-cladd-md px-2 font-medium text-cladd-fg text-cladd-xs";
export const textarea =
"cladd-cut cladd-hoverable cladd-focusable-input min-h-24 w-full min-w-0 rounded-cladd-md px-2.5 py-1.5 font-medium text-cladd-fg text-cladd-xs";
export const muted = "text-[12px] text-cladd-fg-soft";
+14
View File
@@ -0,0 +1,14 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
export function setQuery(values: Record<string, string | null | undefined>) {
const url = new URL(page.url.href);
for (const [key, value] of Object.entries(values)) {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
}
return goto(url, { replace: true, state: page.state });
}