feat(shell,api,keys): firefly v1 shell over the api with cookie auth, period, palette and keymap
This commit is contained in:
1 parent
ae4de7911b
commit
c74dac3e00
37 files changed
+58163
-79
No files matched your search
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
||||
|
||||
export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
|
||||
|
||||
let {
|
||||
amount,
|
||||
code,
|
||||
symbol,
|
||||
decimals,
|
||||
tone = "auto",
|
||||
signed = false,
|
||||
class: className = "",
|
||||
}: Money & { tone?: Tone; signed?: boolean; class?: string } = $props();
|
||||
|
||||
const value = $derived(toNumber(amount));
|
||||
const resolved = $derived.by(() => {
|
||||
if (tone !== "auto") {
|
||||
return tone;
|
||||
}
|
||||
if (value > 0) {
|
||||
return "income";
|
||||
}
|
||||
return value < 0 ? "expense" : "neutral";
|
||||
});
|
||||
const color = $derived(
|
||||
{
|
||||
expense: "text-expense",
|
||||
income: "text-income",
|
||||
neutral: "text-cladd-fg-softer",
|
||||
transfer: "text-transfer",
|
||||
}[resolved]
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="num {color} {className}"
|
||||
>{formatMoney({ amount, code, decimals, symbol }, signed)}</span
|
||||
>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
href,
|
||||
tools,
|
||||
footer,
|
||||
children,
|
||||
class: className = "",
|
||||
body = "p-3",
|
||||
}: {
|
||||
title?: string;
|
||||
href?: string;
|
||||
tools?: Snippet;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
body?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {className}"
|
||||
>
|
||||
{#if title || tools}
|
||||
<header
|
||||
class="flex min-h-10 items-center justify-between gap-3 border-cladd-outline border-b px-3 py-1.5"
|
||||
>
|
||||
{#if title}
|
||||
<h2 class="truncate font-semibold text-[13px] text-cladd-fg">
|
||||
{#if href}
|
||||
<a class="hover:underline" {href}>{title}</a>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if tools}
|
||||
<div class="flex shrink-0 items-center gap-1.5">{@render tools()}</div>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
<div class="min-h-0 flex-1 {body}">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if footer}
|
||||
<footer
|
||||
class="flex items-center gap-2 border-cladd-outline border-t px-3 py-2 text-[12px]"
|
||||
>
|
||||
{@render footer()}
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const ARM_MS = 3000;
|
||||
|
||||
let {
|
||||
onconfirm,
|
||||
children,
|
||||
armed = $bindable(false),
|
||||
confirmLabel = "Press again to delete",
|
||||
class: className = "",
|
||||
disabled = false,
|
||||
}: {
|
||||
onconfirm: () => void | Promise<void>;
|
||||
children: Snippet;
|
||||
armed?: boolean;
|
||||
confirmLabel?: string;
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
export async function press() {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
if (!armed) {
|
||||
armed = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (armed = false), ARM_MS);
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
busy = true;
|
||||
try {
|
||||
await onconfirm();
|
||||
} finally {
|
||||
busy = false;
|
||||
armed = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
aria-live="polite"
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] {armed
|
||||
? "cladd-color-red text-cladd-primary"
|
||||
: "text-cladd-fg"} {className}"
|
||||
disabled={disabled || busy}
|
||||
onblur={() => (armed = false)}
|
||||
onclick={press}
|
||||
type="button"
|
||||
>
|
||||
{#if armed}
|
||||
{confirmLabel}
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ui } from "#lib/ui.svelte.js";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="border-cladd-outline border-b bg-cladd-surface px-6 py-3 text-[13px]"
|
||||
>
|
||||
<button onclick={() => (ui.quickEntry = false)} type="button">Close</button>
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { keys, prettyKeys } from "#lib/keys.svelte.js";
|
||||
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const map = new Map<string, { keys: string; label: string }[]>();
|
||||
const seen = new Set<string>();
|
||||
for (const binding of [...keys.all].reverse()) {
|
||||
if (binding.hidden || seen.has(binding.keys)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(binding.keys);
|
||||
const group = binding.group ?? "Page";
|
||||
map.set(group, [...(map.get(group) ?? []), binding]);
|
||||
}
|
||||
return [...map.entries()].reverse();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (keys.sheetOpen && dialog && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!keys.sheetOpen && dialog?.open) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
aria-label="Keyboard shortcuts"
|
||||
class="cladd-surface cladd-outline m-auto w-[min(920px,calc(100vw-2rem))] rounded-cladd-panel p-0 text-cladd-fg shadow-cladd-popover backdrop:bg-black/30"
|
||||
onclick={(event) => event.target === dialog && (keys.sheetOpen = false)}
|
||||
onclose={() => (keys.sheetOpen = false)}
|
||||
onkeydown={(event) => event.key === "?" && (keys.sheetOpen = false)}
|
||||
bind:this={dialog}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-cladd-outline border-b px-5 py-3"
|
||||
>
|
||||
<h2 class="font-semibold text-[15px]">Keyboard shortcuts</h2>
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
>Every shortcut is also in the palette,
|
||||
<span class="kbd">{prettyKeys("mod+k")[0]}</span></span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="grid max-h-[70vh] grid-cols-1 gap-x-8 gap-y-5 overflow-y-auto px-5 py-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{#each groups as [group, bindings] (group)}
|
||||
<section class="flex flex-col gap-1">
|
||||
<h3
|
||||
class="pb-1 font-semibold text-[11px] text-cladd-fg-softer uppercase tracking-[0.08em]"
|
||||
>
|
||||
{group}
|
||||
</h3>
|
||||
{#each bindings as binding (binding.keys)}
|
||||
<div class="flex h-7 items-center justify-between gap-3 text-[13px]">
|
||||
<span class="truncate">{binding.label}</span>
|
||||
<span class="flex shrink-0 gap-1">
|
||||
{#each prettyKeys(binding.keys) as key, i (i)}
|
||||
<span class="kbd">{key}</span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
export interface Crumb {
|
||||
href?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
crumbs = [],
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
crumbs?: Crumb[];
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} · Firefly III</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 pb-4">
|
||||
<div class="flex min-w-0 items-baseline gap-3">
|
||||
<h1
|
||||
class="truncate font-semibold text-[22px] text-cladd-fg tracking-[-0.02em]"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{#if subtitle}
|
||||
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{@render actions?.()}
|
||||
<ol
|
||||
aria-label="Breadcrumbs"
|
||||
class="flex items-center gap-1.5 text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
<li><a class="hover:text-cladd-fg hover:underline" href="/">Home</a></li>
|
||||
{#each crumbs as crumb (crumb.label)}
|
||||
<li aria-hidden="true" class="text-cladd-fg-softest">/</li>
|
||||
<li>
|
||||
{#if crumb.href}
|
||||
<a class="hover:text-cladd-fg hover:underline" href={crumb.href}
|
||||
>{crumb.label}</a
|
||||
>
|
||||
{:else}
|
||||
<span class="text-cladd-fg">{crumb.label}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script lang="ts">
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
import { keys, prettyKeys } from "#lib/keys.svelte.js";
|
||||
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
|
||||
import { ui } from "#lib/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Entry {
|
||||
group: string;
|
||||
hint?: string;
|
||||
id: string;
|
||||
keys?: string;
|
||||
label: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let input = $state<HTMLInputElement>();
|
||||
let cursor = $state(0);
|
||||
|
||||
const accounts = createQuery(() => ({
|
||||
...lists.accounts("all"),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
const budgets = createQuery(() => ({
|
||||
...lists.budgets(),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
const categories = createQuery(() => ({
|
||||
...lists.categories(),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
const tags = createQuery(() => ({ ...lists.tags(), enabled: ui.palette }));
|
||||
const bills = createQuery(() => ({ ...lists.bills(), enabled: ui.palette }));
|
||||
const piggies = createQuery(() => ({
|
||||
...lists.piggyBanks(),
|
||||
enabled: ui.palette,
|
||||
}));
|
||||
|
||||
function link(href: string, old = false) {
|
||||
return () => {
|
||||
if (old) {
|
||||
location.assign(href);
|
||||
} else {
|
||||
goto(href);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const ACCOUNT_GROUP: Record<string, string> = {
|
||||
asset: "Asset account",
|
||||
expense: "Expense account",
|
||||
liabilities: "Liability",
|
||||
revenue: "Revenue account",
|
||||
};
|
||||
|
||||
function entity(
|
||||
kind: string,
|
||||
group: string,
|
||||
href: string,
|
||||
id: string,
|
||||
label: string | undefined,
|
||||
hint?: string
|
||||
): Entry {
|
||||
return {
|
||||
group,
|
||||
hint,
|
||||
id: `${kind}:${id}`,
|
||||
label: label ?? "",
|
||||
run: link(`${href}/${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
const staticEntries: Entry[] = [
|
||||
...allLinks().map((nav) => ({
|
||||
group: "Page",
|
||||
id: `page:${nav.href}`,
|
||||
keys: nav.keys,
|
||||
label: nav.label,
|
||||
run: link(nav.href, nav.old),
|
||||
})),
|
||||
...CREATE_LINKS.map((create) => ({
|
||||
group: "Create",
|
||||
id: `create:${create.href}`,
|
||||
label: create.label,
|
||||
run: link(create.href, create.old),
|
||||
})),
|
||||
];
|
||||
|
||||
const commandEntries = $derived<Entry[]>(
|
||||
keys.all
|
||||
.filter((binding) => !binding.hidden && binding.group !== "Navigation")
|
||||
.map((binding) => ({
|
||||
group: binding.group ?? "Command",
|
||||
id: `key:${binding.keys}:${binding.label}`,
|
||||
keys: binding.keys,
|
||||
label: binding.label,
|
||||
run: () => binding.run(new KeyboardEvent("keydown")),
|
||||
}))
|
||||
);
|
||||
|
||||
const entityEntries = $derived<Entry[]>([
|
||||
...(accounts.data ?? []).map((item) =>
|
||||
entity(
|
||||
"account",
|
||||
ACCOUNT_GROUP[item.attributes.type] ?? "Account",
|
||||
"/accounts/show",
|
||||
item.id,
|
||||
item.attributes.name,
|
||||
item.attributes.active ? undefined : "inactive"
|
||||
)
|
||||
),
|
||||
...(budgets.data ?? []).map((item) =>
|
||||
entity("budget", "Budget", "/budgets/show", item.id, item.attributes.name)
|
||||
),
|
||||
...(categories.data ?? []).map((item) =>
|
||||
entity(
|
||||
"category",
|
||||
"Category",
|
||||
"/categories/show",
|
||||
item.id,
|
||||
item.attributes.name
|
||||
)
|
||||
),
|
||||
...(tags.data ?? []).map((item) =>
|
||||
entity("tag", "Tag", "/tags/show", item.id, item.attributes.tag)
|
||||
),
|
||||
...(bills.data ?? []).map((item) =>
|
||||
entity(
|
||||
"bill",
|
||||
"Subscription",
|
||||
"/subscriptions/show",
|
||||
item.id,
|
||||
item.attributes.name
|
||||
)
|
||||
),
|
||||
...(piggies.data ?? []).map((item) =>
|
||||
entity(
|
||||
"piggy",
|
||||
"Piggy bank",
|
||||
"/piggy-banks/show",
|
||||
item.id,
|
||||
item.attributes.name
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
const entries = $derived([
|
||||
...staticEntries,
|
||||
...commandEntries,
|
||||
...entityEntries,
|
||||
]);
|
||||
|
||||
const results = $derived.by<Entry[]>(() => {
|
||||
const query = ui.paletteQuery;
|
||||
const found = fuzzyFilter(
|
||||
entries,
|
||||
query,
|
||||
(e) => `${e.label} ${e.group}`,
|
||||
60
|
||||
);
|
||||
if (query.trim()) {
|
||||
found.push({
|
||||
group: "Search",
|
||||
id: "search",
|
||||
label: `Search transactions for “${query.trim()}”`,
|
||||
run: link(`/search?search=${encodeURIComponent(query.trim())}`),
|
||||
});
|
||||
}
|
||||
return found;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ui.palette && dialog && !dialog.open) {
|
||||
cursor = 0;
|
||||
dialog.showModal();
|
||||
input?.select();
|
||||
} else if (!ui.palette && dialog?.open) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ui.paletteQuery !== undefined) {
|
||||
cursor = 0;
|
||||
}
|
||||
});
|
||||
|
||||
function choose(entry: Entry | undefined) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
ui.palette = false;
|
||||
entry.run();
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
cursor = (cursor + delta + results.length) % results.length;
|
||||
document
|
||||
.getElementById(`palette-${cursor}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
const ctrl = event.ctrlKey || event.metaKey;
|
||||
if (
|
||||
event.key === "ArrowDown" ||
|
||||
(ctrl && event.key === "j") ||
|
||||
(ctrl && event.key === "n")
|
||||
) {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (
|
||||
event.key === "ArrowUp" ||
|
||||
(ctrl && event.key === "k") ||
|
||||
(ctrl && event.key === "p")
|
||||
) {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
choose(results[cursor]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
aria-label="Command palette"
|
||||
class="cladd-surface cladd-outline m-auto mt-[12vh] w-[min(640px,calc(100vw-2rem))] rounded-cladd-panel p-0 text-cladd-fg shadow-cladd-popover backdrop:bg-black/30"
|
||||
onclose={() => (ui.palette = false)}
|
||||
bind:this={dialog}
|
||||
>
|
||||
<div class="flex items-center gap-2 border-cladd-outline border-b px-4">
|
||||
<Search class="size-4 text-cladd-fg-softer" />
|
||||
<input
|
||||
aria-activedescendant={`palette-${cursor}`}
|
||||
aria-controls="palette-list"
|
||||
aria-expanded="true"
|
||||
class="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-cladd-fg-softer"
|
||||
{onkeydown}
|
||||
placeholder="Go to page, account, budget, category, tag, or run a command"
|
||||
role="combobox"
|
||||
bind:this={input}
|
||||
bind:value={ui.paletteQuery}
|
||||
>
|
||||
<span class="kbd">Esc</span>
|
||||
</div>
|
||||
<ul class="max-h-[56vh] overflow-y-auto p-1.5" id="palette-list">
|
||||
{#each results as entry, index (entry.id)}
|
||||
<li>
|
||||
<button
|
||||
class="flex h-9 w-full cursor-pointer items-center gap-3 rounded-cladd-md px-3 text-left {index ===
|
||||
cursor
|
||||
? "bg-row-cursor"
|
||||
: ""}"
|
||||
id={`palette-${index}`}
|
||||
onclick={() => choose(entry)}
|
||||
onmousemove={() => (cursor = index)}
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-[13px]">{entry.label}</span>
|
||||
{#if entry.hint}
|
||||
<span class="text-[11px] text-cladd-fg-softer">{entry.hint}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="w-28 shrink-0 text-right text-[11px] text-cladd-fg-softer"
|
||||
>{entry.group}</span
|
||||
>
|
||||
<span class="flex w-16 shrink-0 justify-end gap-1">
|
||||
{#if entry.keys}
|
||||
{#each prettyKeys(entry.keys) as key, i (i)}
|
||||
<span class="kbd">{key}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-3 py-6 text-center text-[13px] text-cladd-fg-soft">
|
||||
Nothing matches. Try fewer letters.
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</dialog>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import CalendarRange from "@lucide/svelte/icons/calendar-range";
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import { Popover } from "bits-ui";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
|
||||
let open = $state(false);
|
||||
let start = $state(period.start);
|
||||
let end = $state(period.end);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
({ start, end } = period);
|
||||
}
|
||||
});
|
||||
|
||||
function apply(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (start && end && start <= end) {
|
||||
period.set({ end, start });
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center rounded-cladd-md bg-black/10 text-on-brand">
|
||||
<button
|
||||
aria-label="Previous period"
|
||||
class="grid size-8 place-items-center rounded-l-cladd-md hover:bg-black/10"
|
||||
onclick={() => period.shift(-1)}
|
||||
title="Previous period ["
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</button>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
class="flex h-8 items-center gap-2 px-2 font-medium text-[13px] hover:bg-black/10"
|
||||
title="Choose period"
|
||||
>
|
||||
<CalendarRange class="size-3.5 opacity-80" />
|
||||
<span class="num">{period.label}</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-level-1 cladd-anim-popover z-50 w-72 rounded-cladd-lg p-2 text-cladd-fg shadow-cladd-popover outline-none"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
{#each period.presets() as preset (preset.label)}
|
||||
{@const current =
|
||||
preset.range.start === period.start &&
|
||||
preset.range.end === period.end}
|
||||
<button
|
||||
class="flex h-8 items-center justify-between rounded-cladd-sm px-2.5 text-left text-[13px] hover:bg-cladd-surface-next {current
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
onclick={() => {
|
||||
period.set(preset.range);
|
||||
open = false;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form
|
||||
class="mt-2 flex flex-col gap-2 border-cladd-outline border-t px-1 pt-3"
|
||||
onsubmit={apply}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex flex-col gap-1 text-[11px] text-cladd-fg-soft">
|
||||
Start
|
||||
<input
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
type="date"
|
||||
bind:value={start}
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-[11px] text-cladd-fg-soft">
|
||||
End
|
||||
<input
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
type="date"
|
||||
bind:value={end}
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand h-8 rounded-cladd-sm font-semibold text-[12px] text-cladd-primary"
|
||||
type="submit"
|
||||
>
|
||||
Apply custom range
|
||||
</button>
|
||||
</form>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<button
|
||||
aria-label="Next period"
|
||||
class="grid size-8 place-items-center rounded-r-cladd-md hover:bg-black/10"
|
||||
onclick={() => period.shift(1)}
|
||||
title="Next period ]"
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import Eye from "@lucide/svelte/icons/eye";
|
||||
import EyeOff from "@lucide/svelte/icons/eye-off";
|
||||
import History from "@lucide/svelte/icons/history";
|
||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
||||
import PanelLeft from "@lucide/svelte/icons/panel-left";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import { setMode, userPrefersMode } from "mode-watcher";
|
||||
import type { Snippet } from "svelte";
|
||||
import { logout } from "#lib/api/client.js";
|
||||
import { invalidate } from "#lib/api/lists.js";
|
||||
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||
import { isEditable, keys, useKeys } from "#lib/keys.svelte.js";
|
||||
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { session, TESTED_VERSION } from "#lib/session.svelte.js";
|
||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||
import { ui } from "#lib/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import Cheatsheet from "./Cheatsheet.svelte";
|
||||
import Palette from "./Palette.svelte";
|
||||
import PeriodPicker from "./PeriodPicker.svelte";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
|
||||
let { children, quick }: { children: Snippet; quick?: Snippet } = $props();
|
||||
|
||||
function openOld(sticky = false) {
|
||||
location.assign(oldUiUrl(page.url.href, sticky));
|
||||
}
|
||||
|
||||
async function togglePrivacy() {
|
||||
await session.setPreference("anonymous", !session.anonymous);
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
const order = ["system", "light", "dark"] as const;
|
||||
const current = userPrefersMode.current ?? "system";
|
||||
setMode(order[(order.indexOf(current) + 1) % order.length]);
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
const input = document.getElementById("sidebar-search");
|
||||
if (input && input.offsetParent !== null) {
|
||||
input.focus();
|
||||
} else {
|
||||
goto("/search");
|
||||
}
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Global",
|
||||
input: true,
|
||||
keys: "mod+k",
|
||||
label: "Command palette",
|
||||
run: () => ui.openPalette(),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "?",
|
||||
label: "Keyboard shortcuts",
|
||||
run: () => (keys.sheetOpen = !keys.sheetOpen),
|
||||
},
|
||||
{ group: "Global", keys: "/", label: "Search", run: focusSearch },
|
||||
{
|
||||
group: "Global",
|
||||
keys: "n",
|
||||
label: "Quick entry",
|
||||
run: () => (ui.quickEntry = true),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "N",
|
||||
label: "New transaction (full editor)",
|
||||
run: () => goto("/transactions/create/withdrawal"),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "O",
|
||||
label: "Open this page in the old interface",
|
||||
run: () => openOld(),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "g O",
|
||||
label: "Stay in the old interface",
|
||||
run: () => openOld(true),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
keys: "\\",
|
||||
label: "Toggle sidebar",
|
||||
run: () => ui.toggleSidebar(),
|
||||
},
|
||||
{
|
||||
group: "Period",
|
||||
keys: "[",
|
||||
label: "Previous period",
|
||||
run: () => period.shift(-1),
|
||||
},
|
||||
{
|
||||
group: "Period",
|
||||
keys: "]",
|
||||
label: "Next period",
|
||||
run: () => period.shift(1),
|
||||
},
|
||||
{
|
||||
group: "Period",
|
||||
keys: ".",
|
||||
label: "Current period",
|
||||
run: () => period.reset(),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
hidden: true,
|
||||
keys: "g g",
|
||||
label: "Top of page",
|
||||
run: () => window.scrollTo({ top: 0 }),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
hidden: true,
|
||||
keys: "G",
|
||||
label: "Bottom of page",
|
||||
run: () => window.scrollTo({ top: document.body.scrollHeight }),
|
||||
},
|
||||
{
|
||||
group: "Global",
|
||||
hidden: true,
|
||||
input: true,
|
||||
keys: "Escape",
|
||||
label: "Leave field",
|
||||
run: (event) => {
|
||||
if (isEditable(event.target)) {
|
||||
(event.target as HTMLElement).blur();
|
||||
}
|
||||
ui.mobileNav = false;
|
||||
ui.quickEntry = false;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "Settings",
|
||||
keys: "g T",
|
||||
label: "Cycle theme: system, light, dark",
|
||||
run: cycleTheme,
|
||||
},
|
||||
{
|
||||
group: "Settings",
|
||||
keys: "g P",
|
||||
label: "Toggle privacy mode",
|
||||
run: togglePrivacy,
|
||||
},
|
||||
{ group: "Global", keys: "g q", label: "Logout", run: logout },
|
||||
...allLinks()
|
||||
.filter((link) => link.keys)
|
||||
.map((link) => ({
|
||||
group: "Navigation",
|
||||
keys: link.keys as string,
|
||||
label: `Go to ${link.label.toLowerCase()}`,
|
||||
run: () => (link.old ? location.assign(link.href) : goto(link.href)),
|
||||
})),
|
||||
{
|
||||
group: "Navigation",
|
||||
keys: "g f",
|
||||
label: "Go to search",
|
||||
run: () => goto("/search"),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh bg-cladd-bg">
|
||||
<header
|
||||
class="sticky top-0 z-40 flex h-12 items-center bg-brand text-on-brand shadow-[0_1px_0_rgb(0_0_0/0.08)]"
|
||||
>
|
||||
<a
|
||||
class="flex h-full shrink-0 items-center gap-2 px-4 text-[17px] tracking-[-0.01em] {ui.collapsed
|
||||
? "lg:w-14 lg:justify-center lg:px-0"
|
||||
: "lg:w-60"}"
|
||||
href="/"
|
||||
>
|
||||
{#if ui.collapsed}
|
||||
<strong class="hidden lg:inline">FF</strong>
|
||||
<span class="lg:hidden"><strong>Firefly</strong>III</span>
|
||||
{:else}
|
||||
<span><strong>Firefly</strong>III</span>
|
||||
{/if}
|
||||
</a>
|
||||
<button
|
||||
aria-label="Toggle sidebar"
|
||||
class="grid size-12 shrink-0 place-items-center hover:bg-black/10"
|
||||
onclick={() => ui.toggleSidebar()}
|
||||
title="Toggle sidebar \"
|
||||
type="button"
|
||||
>
|
||||
<PanelLeft class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
class="ml-1 hidden h-8 min-w-0 items-center gap-2 rounded-cladd-md bg-black/10 px-3 text-[13px] text-on-brand/80 hover:bg-black/15 md:flex md:w-64 xl:w-80"
|
||||
onclick={() => ui.openPalette()}
|
||||
type="button"
|
||||
>
|
||||
<span class="flex-1 truncate text-left">Jump to anything</span>
|
||||
<span class="rounded bg-black/15 px-1.5 py-0.5 font-mono text-[11px]"
|
||||
>⌘K</span
|
||||
>
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-1 pr-2">
|
||||
<button
|
||||
aria-label={session.anonymous ? "Show amounts" : "Hide amounts"}
|
||||
aria-pressed={session.anonymous}
|
||||
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
|
||||
onclick={togglePrivacy}
|
||||
title={session.anonymous
|
||||
? "Amounts hidden. Show amounts"
|
||||
: "Hide amounts"}
|
||||
type="button"
|
||||
>
|
||||
{#if session.anonymous}
|
||||
<EyeOff class="size-4" />
|
||||
{:else}
|
||||
<Eye class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Keyboard shortcuts"
|
||||
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
|
||||
onclick={() => (keys.sheetOpen = true)}
|
||||
title="Keyboard shortcuts ?"
|
||||
type="button"
|
||||
>
|
||||
<Keyboard class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Open this page in the old interface"
|
||||
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
|
||||
onclick={() => openOld()}
|
||||
title="Open this page in the old interface Shift+O"
|
||||
type="button"
|
||||
>
|
||||
<History class="size-4" />
|
||||
</button>
|
||||
<PeriodPicker />
|
||||
<span class="hidden px-2 text-[12px] text-on-brand/85 xl:inline"
|
||||
>{session.email}</span
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
aria-label="Create new"
|
||||
class="grid size-8 place-items-center rounded-cladd-md hover:bg-black/10"
|
||||
title="Create new"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenuContent align="end" class="w-60">
|
||||
{#each CREATE_LINKS as link (link.href)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
link.old ? location.assign(link.href) : goto(link.href)}
|
||||
>
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
{/each}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex">
|
||||
<aside
|
||||
class="fixed top-12 bottom-0 z-30 w-60 shrink-0 border-cladd-outline border-r bg-cladd-surface transition-transform lg:sticky lg:h-[calc(100dvh-3rem)] lg:translate-x-0 {ui.collapsed
|
||||
? "lg:w-14"
|
||||
: "lg:w-60"} {ui.mobileNav ? "translate-x-0" : "-translate-x-full"}"
|
||||
>
|
||||
<Sidebar
|
||||
collapsed={ui.collapsed && !ui.mobileNav}
|
||||
onnavigate={() => (ui.mobileNav = false)}
|
||||
/>
|
||||
</aside>
|
||||
{#if ui.mobileNav}
|
||||
<button
|
||||
aria-label="Close menu"
|
||||
class="fixed inset-0 top-12 z-20 bg-black/30 lg:hidden"
|
||||
onclick={() => (ui.mobileNav = false)}
|
||||
type="button"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-[calc(100dvh-3rem)] min-w-0 flex-1 flex-col">
|
||||
{#if ui.quickEntry && quick}
|
||||
{@render quick()}
|
||||
{/if}
|
||||
<main class="mx-auto w-full max-w-[1920px] flex-1 px-4 py-4 lg:px-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
<footer
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-cladd-outline border-t px-4 py-2.5 text-[12px] text-cladd-fg-soft lg:px-6"
|
||||
>
|
||||
<span>
|
||||
Firefly III {session.version}
|
||||
{#if session.drift}
|
||||
<span class="text-warn"
|
||||
>· this interface was tested with {TESTED_VERSION}, some screens
|
||||
may misbehave</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5"
|
||||
>Press <span class="kbd">?</span> for shortcuts,
|
||||
<span class="kbd">Shift+O</span>
|
||||
for the old interface</span
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Palette />
|
||||
<Cheatsheet />
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import LogOut from "@lucide/svelte/icons/log-out";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import { logout } from "#lib/api/client.js";
|
||||
import { isActive, NAV, type NavItem } from "#lib/nav.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let {
|
||||
collapsed = false,
|
||||
onnavigate,
|
||||
}: { collapsed?: boolean; onnavigate?: () => void } = $props();
|
||||
|
||||
let query = $state("");
|
||||
let opened = $state<Record<string, boolean>>({});
|
||||
|
||||
const pathname = $derived(page.url.pathname);
|
||||
|
||||
function isOpen(item: NavItem) {
|
||||
return opened[item.label] ?? isActive(item, pathname);
|
||||
}
|
||||
|
||||
function toggle(item: NavItem) {
|
||||
opened[item.label] = !isOpen(item);
|
||||
}
|
||||
|
||||
function search(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
goto(`/search?search=${encodeURIComponent(query)}`);
|
||||
onnavigate?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
aria-label="Main"
|
||||
class="flex h-full flex-col gap-3 overflow-y-auto px-2 py-3"
|
||||
>
|
||||
{#if !collapsed}
|
||||
<search>
|
||||
<form class="relative px-1" onsubmit={search}>
|
||||
<Search
|
||||
class="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-cladd-fg-softer"
|
||||
/>
|
||||
<input
|
||||
class="cladd-cut cladd-focusable-input h-8 w-full rounded-cladd-md pr-2 pl-8 text-[13px] text-cladd-fg placeholder:text-cladd-fg-softer"
|
||||
id="sidebar-search"
|
||||
name="search"
|
||||
placeholder="Search..."
|
||||
type="search"
|
||||
bind:value={query}
|
||||
>
|
||||
</form>
|
||||
</search>
|
||||
{/if}
|
||||
|
||||
{#each NAV as section, index (index)}
|
||||
<div class="flex flex-col gap-px">
|
||||
{#if section.title && !collapsed}
|
||||
<div
|
||||
class="px-2.5 pt-1 pb-1 font-semibold text-[10.5px] text-cladd-fg-softer uppercase tracking-[0.08em]"
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
{/if}
|
||||
{#each section.items as item (item.label)}
|
||||
{@const active = isActive(item, pathname)}
|
||||
{#if item.children}
|
||||
<button
|
||||
aria-expanded={isOpen(item)}
|
||||
class="group flex h-8 w-full items-center gap-2.5 rounded-cladd-sm px-2.5 text-left text-cladd-fg hover:bg-cladd-surface-next {active
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
onclick={() => (collapsed ? goto(item.href) : toggle(item))}
|
||||
title={collapsed ? item.label : undefined}
|
||||
type="button"
|
||||
>
|
||||
<item.icon
|
||||
class="size-4 shrink-0 {active
|
||||
? "text-brand dark:text-(--ff-series-1)"
|
||||
: "text-cladd-fg-soft"}"
|
||||
/>
|
||||
{#if !collapsed}
|
||||
<span class="flex-1 truncate">{item.label}</span>
|
||||
<ChevronRight
|
||||
class="size-3.5 text-cladd-fg-softer transition-transform {isOpen(
|
||||
item
|
||||
)
|
||||
? "rotate-90"
|
||||
: ""}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isOpen(item) && !collapsed}
|
||||
<div
|
||||
class="mb-1 ml-[1.15rem] flex flex-col gap-px border-cladd-outline border-l pl-2"
|
||||
>
|
||||
{#each item.children as child (child.href)}
|
||||
{@const childActive = isActive(child, pathname)}
|
||||
<a
|
||||
aria-current={childActive ? "page" : undefined}
|
||||
class="flex h-7 items-center justify-between gap-2 rounded-cladd-sm px-2.5 text-[13px] hover:bg-cladd-surface-next {childActive
|
||||
? "bg-cladd-surface-next font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft"}"
|
||||
data-sveltekit-reload={child.old ? "" : undefined}
|
||||
href={child.href}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="truncate">{child.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
aria-current={active ? "page" : undefined}
|
||||
class="flex h-8 items-center gap-2.5 rounded-cladd-sm px-2.5 hover:bg-cladd-surface-next {active
|
||||
? "bg-cladd-surface-next font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg"}"
|
||||
data-sveltekit-reload={item.old ? "" : undefined}
|
||||
href={item.href}
|
||||
onclick={onnavigate}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<item.icon
|
||||
class="size-4 shrink-0 {active
|
||||
? "text-brand dark:text-(--ff-series-1)"
|
||||
: "text-cladd-fg-soft"}"
|
||||
/>
|
||||
{#if !collapsed}
|
||||
<span class="truncate">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="mt-auto flex h-8 items-center gap-2.5 rounded-cladd-sm px-2.5 text-left text-cladd-fg-soft hover:bg-cladd-surface-next"
|
||||
onclick={logout}
|
||||
title={collapsed ? "Logout" : undefined}
|
||||
type="button"
|
||||
>
|
||||
<LogOut class="size-4 shrink-0" />
|
||||
{#if !collapsed}
|
||||
<span>Logout</span>
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
Reference in new issue
Block a user