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,162 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, invalidate } from "#lib/api/lists.js";
|
||||
import type { Attachment, Item } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
|
||||
import ListTable from "#lib/components/misc/ListTable.svelte";
|
||||
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
|
||||
import { dateOnly, sizeText } from "#lib/components/misc/queries.js";
|
||||
import {
|
||||
link,
|
||||
muted,
|
||||
td,
|
||||
tdNum,
|
||||
th,
|
||||
thNum,
|
||||
} from "#lib/components/misc/styles.js";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import AttachedTo from "./AttachedTo.svelte";
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryFn: () => all<Attachment>("/v1/attachments"),
|
||||
queryKey: ["attachments"],
|
||||
}));
|
||||
|
||||
const attachments = $derived(
|
||||
[...(query.data ?? [])].sort((a, b) =>
|
||||
(b.attributes.created_at ?? "").localeCompare(
|
||||
a.attributes.created_at ?? ""
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const arm = new Arm();
|
||||
let notes = $state<Record<string, string>>({});
|
||||
|
||||
const view = (item: Item<Attachment>) =>
|
||||
window.open(`/attachments/view/${item.id}`, "_blank", "noopener");
|
||||
const save = (item: Item<Attachment>) =>
|
||||
location.assign(`/attachments/download/${item.id}`);
|
||||
const edit = (item: Item<Attachment>) => goto(`/attachments/edit/${item.id}`);
|
||||
|
||||
async function remove(item: Item<Attachment>) {
|
||||
try {
|
||||
await api(`/v1/attachments/${item.id}`, { method: "DELETE" });
|
||||
await invalidate();
|
||||
} catch (error) {
|
||||
notes = { ...notes, [item.id]: errorText(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const nav: ListNav<Item<Attachment>> = new ListNav({
|
||||
edit,
|
||||
extra: () => [
|
||||
{
|
||||
group: "Attachments",
|
||||
keys: "d",
|
||||
label: "Download file",
|
||||
run: () => nav.current && save(nav.current),
|
||||
},
|
||||
{
|
||||
group: "Attachments",
|
||||
keys: "D",
|
||||
label: "Delete attachment (press twice)",
|
||||
run: () => {
|
||||
const item = nav.current;
|
||||
if (item) {
|
||||
arm.press(item.id, () => remove(item));
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
id: "attachments",
|
||||
items: () => attachments,
|
||||
key: (item) => item.id,
|
||||
open: view,
|
||||
});
|
||||
nav.use();
|
||||
</script>
|
||||
|
||||
<PageHeader crumbs={[{ label: "Attachments" }]} title="Attachments" />
|
||||
|
||||
<Box body="p-0" title="List of all attachments">
|
||||
{#if query.isPending}
|
||||
<p class="p-3 {muted}">Loading attachments</p>
|
||||
{:else if query.error}
|
||||
<p class="p-3 text-expense">{errorText(query.error)}</p>
|
||||
{:else}
|
||||
<ListTable
|
||||
empty="There are no attachments yet. Add them to a transaction, account or budget when you edit it."
|
||||
key={(item) => item.id}
|
||||
{nav}
|
||||
>
|
||||
{#snippet head()}
|
||||
<th class={th}>File name</th>
|
||||
<th class={th}>Title</th>
|
||||
<th class="{thNum} w-24">Size</th>
|
||||
<th class="{th} hidden sm:table-cell w-40">Type</th>
|
||||
<th class={th}>Attached to</th>
|
||||
<th class={th}>Notes</th>
|
||||
<th class="{th} hidden sm:table-cell w-28">Uploaded</th>
|
||||
<th class={th}></th>
|
||||
{/snippet}
|
||||
{#snippet row(
|
||||
item
|
||||
)}
|
||||
{@const a = item.attributes}
|
||||
<td class={td}>
|
||||
<a
|
||||
class={link}
|
||||
data-sveltekit-reload
|
||||
href="/attachments/view/{item.id}"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>{a.filename}</a
|
||||
>
|
||||
</td>
|
||||
<td class={td}>{a.title ?? ""}</td>
|
||||
<td class={tdNum}>{sizeText(a.size)}</td>
|
||||
<td class="{td} hidden sm:table-cell text-cladd-fg-soft">{a.mime}</td>
|
||||
<td class={td}>
|
||||
<AttachedTo id={a.attachable_id} type={a.attachable_type} />
|
||||
</td>
|
||||
<td class="{td} whitespace-pre-line text-[12px] text-cladd-fg-soft">
|
||||
{a.notes ?? ""}
|
||||
</td>
|
||||
<td class="{td} hidden sm:table-cell num">
|
||||
{formatDate(dateOnly(a.created_at))}
|
||||
</td>
|
||||
<td class="{td} text-[12px]">
|
||||
{#if arm.key === item.id}
|
||||
<span class="text-expense">Press D again to delete</span>
|
||||
{:else if notes[item.id]}
|
||||
<span class="text-expense">{notes[item.id]}</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/snippet}
|
||||
{#snippet menu(
|
||||
item
|
||||
)}
|
||||
<ContextMenuItem onSelect={() => view(item)}>
|
||||
View
|
||||
<span class="ml-auto kbd">↵</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => save(item)}>
|
||||
Download
|
||||
<span class="ml-auto kbd">d</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => edit(item)}>
|
||||
Edit
|
||||
<span class="ml-auto kbd">e</span>
|
||||
</ContextMenuItem>
|
||||
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
|
||||
{/snippet}
|
||||
</ListTable>
|
||||
{/if}
|
||||
</Box>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import type { One, TransactionGroup } from "#lib/api/types.js";
|
||||
import { link } from "#lib/components/misc/styles.js";
|
||||
|
||||
interface Target {
|
||||
href: string;
|
||||
path: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
type Named = Record<string, unknown>;
|
||||
|
||||
let { type, id }: { type?: string | null; id?: string | null } = $props();
|
||||
|
||||
const TARGETS: Record<string, Target> = {
|
||||
Account: { href: "/accounts/show/", path: "/v1/accounts/", title: "name" },
|
||||
Bill: { href: "/subscriptions/show/", path: "/v1/bills/", title: "name" },
|
||||
Budget: { href: "/budgets/show/", path: "/v1/budgets/", title: "name" },
|
||||
Category: {
|
||||
href: "/categories/show/",
|
||||
path: "/v1/categories/",
|
||||
title: "name",
|
||||
},
|
||||
PiggyBank: {
|
||||
href: "/piggy-banks/show/",
|
||||
path: "/v1/piggy-banks/",
|
||||
title: "name",
|
||||
},
|
||||
Recurrence: {
|
||||
href: "/recurring/show/",
|
||||
path: "/v1/recurrences/",
|
||||
title: "title",
|
||||
},
|
||||
Tag: { href: "/tags/show/", path: "/v1/tags/", title: "tag" },
|
||||
};
|
||||
|
||||
async function resolve(): Promise<{ href: string; label: string }> {
|
||||
if (type === "TransactionJournal") {
|
||||
const group = await api<One<TransactionGroup>>(
|
||||
`/v1/transaction-journals/${id}`
|
||||
);
|
||||
const split = group.data.attributes.transactions.find(
|
||||
(s) => s.transaction_journal_id === id
|
||||
);
|
||||
return {
|
||||
href: `/transactions/show/${group.data.id}`,
|
||||
label:
|
||||
split?.description ?? group.data.attributes.group_title ?? `#${id}`,
|
||||
};
|
||||
}
|
||||
const target = TARGETS[type ?? ""];
|
||||
const body = await api<One<Named>>(`${target.path}${id}`);
|
||||
return {
|
||||
href: `${target.href}${id}`,
|
||||
label: String(body.data.attributes[target.title] ?? `#${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
const known = $derived(
|
||||
type === "TransactionJournal" || Boolean(TARGETS[type ?? ""])
|
||||
);
|
||||
const query = createQuery(() => ({
|
||||
enabled: known && Boolean(id),
|
||||
queryFn: resolve,
|
||||
queryKey: ["attachable", type, id],
|
||||
}));
|
||||
</script>
|
||||
|
||||
{#if query.data}
|
||||
<a class={link} href={query.data.href}>{query.data.label}</a>
|
||||
{:else if query.error || !known}
|
||||
<span class="text-cladd-fg-soft">{type} #{id}</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { invalidate } from "#lib/api/lists.js";
|
||||
import type { Attachment, One } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||
import { Action, errorText } from "#lib/components/misc/action.svelte.js";
|
||||
import Field from "#lib/components/misc/Field.svelte";
|
||||
import { sizeText } from "#lib/components/misc/queries.js";
|
||||
import Status from "#lib/components/misc/Status.svelte";
|
||||
import { link, muted, textarea } from "#lib/components/misc/styles.js";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import Button from "#lib/components/ui/button/button.svelte";
|
||||
import Input from "#lib/components/ui/input/input.svelte";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import AttachedTo from "../../AttachedTo.svelte";
|
||||
|
||||
const id = $derived(page.params.id ?? "");
|
||||
const query = createQuery(() => ({
|
||||
queryFn: () => api<One<Attachment>>(`/v1/attachments/${id}`),
|
||||
queryKey: ["attachment", id],
|
||||
}));
|
||||
const attachment = $derived(query.data?.data.attributes);
|
||||
|
||||
let title = $derived(attachment?.title ?? "");
|
||||
let notes = $derived(attachment?.notes ?? "");
|
||||
|
||||
const save = new Action();
|
||||
const removal = new Action();
|
||||
let element: HTMLFormElement | undefined = $state();
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const cleanNotes = notes.trim();
|
||||
const cleanTitle = title.trim();
|
||||
const kept = !cleanNotes && Boolean(attachment?.notes);
|
||||
const ok = await save.run(async () => {
|
||||
await api(`/v1/attachments/${id}`, {
|
||||
body: {
|
||||
...(cleanNotes ? { notes: cleanNotes } : {}),
|
||||
...(cleanTitle ? { title: cleanTitle } : {}),
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
await invalidate();
|
||||
});
|
||||
if (ok && kept) {
|
||||
save.message =
|
||||
"Saved. The old notes stay: Firefly III cannot remove notes through its API";
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const ok = await removal.run(
|
||||
() => api(`/v1/attachments/${id}`, { method: "DELETE" }),
|
||||
"Deleted"
|
||||
);
|
||||
if (ok) {
|
||||
await invalidate();
|
||||
await goto("/attachments");
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = $derived(
|
||||
attachment ? `Edit attachment "${attachment.filename}"` : "Edit attachment"
|
||||
);
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Form",
|
||||
input: true,
|
||||
keys: "mod+Enter",
|
||||
label: "Update attachment",
|
||||
run: () => element?.requestSubmit(),
|
||||
},
|
||||
{
|
||||
group: "Attachments",
|
||||
keys: "d",
|
||||
label: "Download file",
|
||||
run: () => location.assign(`/attachments/download/${id}`),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
crumbs={[
|
||||
{ href: "/attachments", label: "Attachments" },
|
||||
{ label: pageTitle },
|
||||
]}
|
||||
title={pageTitle}
|
||||
/>
|
||||
|
||||
{#if attachment}
|
||||
<form class="max-w-3xl" onsubmit={submit} bind:this={element}>
|
||||
<Box title="Attachment">
|
||||
<div class="flex flex-col gap-3">
|
||||
<Field label="File name">
|
||||
<p class="pt-1.5">
|
||||
<a
|
||||
class={link}
|
||||
data-sveltekit-reload
|
||||
href="/attachments/view/{id}"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>{attachment.filename}</a
|
||||
>
|
||||
<a
|
||||
class="ml-3 text-[12px] {link}"
|
||||
data-sveltekit-reload
|
||||
href="/attachments/download/{id}"
|
||||
>Download</a
|
||||
>
|
||||
</p>
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<p class="pt-1.5">{attachment.mime}</p>
|
||||
</Field>
|
||||
<Field label="Size">
|
||||
<p class="num pt-1.5">{sizeText(attachment.size)}</p>
|
||||
</Field>
|
||||
<Field label="Attached to">
|
||||
<p class="pt-1.5">
|
||||
<AttachedTo
|
||||
id={attachment.attachable_id}
|
||||
type={attachment.attachable_type}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field for="title" label="Title" problem={save.field("title")}>
|
||||
<Input id="title" bind:value={title} />
|
||||
</Field>
|
||||
<Field for="notes" label="Notes" problem={save.field("notes")}>
|
||||
<textarea
|
||||
class={textarea}
|
||||
id="notes"
|
||||
rows="5"
|
||||
bind:value={notes}
|
||||
></textarea>
|
||||
</Field>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<Button disabled={save.busy} size="sm" type="submit"
|
||||
>Update attachment</Button
|
||||
>
|
||||
<Status action={save} />
|
||||
<span class="ml-auto flex items-center gap-2">
|
||||
<Status action={removal} busy="Deleting" />
|
||||
<ConfirmButton onconfirm={remove}>Delete attachment</ConfirmButton>
|
||||
</span>
|
||||
{/snippet}
|
||||
</Box>
|
||||
</form>
|
||||
{:else if query.error}
|
||||
<p class="text-expense">{errorText(query.error)}</p>
|
||||
{:else}
|
||||
<p class={muted}>Loading attachment</p>
|
||||
{/if}
|
||||
Reference in new issue
Block a user