feat(currencies,preferences,export,misc): currencies, rates, attachments, link types, preferences, export, administrations, recurring and webhooks
This commit is contained in:
1 parent
89bbf56062
commit
f8a5fd6c6d
47 files changed
+4496
-8
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user