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

+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>