init
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ArrowLeftRight,
|
||||
FolderTree,
|
||||
Tags,
|
||||
Store,
|
||||
Wallet,
|
||||
Sparkles,
|
||||
Settings as SettingsIcon,
|
||||
RefreshCw,
|
||||
Menu,
|
||||
X,
|
||||
ShieldCheck,
|
||||
CheckCircle2,
|
||||
CircleHelp,
|
||||
} from "lucide-react";
|
||||
import type { State } from "./api";
|
||||
import { APIError, emptyFilter, normalizeState, request } from "./api";
|
||||
import { Overview } from "./Overview";
|
||||
import { Transactions } from "./Transactions";
|
||||
import { Registry } from "./Registry";
|
||||
import { Accounts } from "./Accounts";
|
||||
import { Classification } from "./Classification";
|
||||
import { Settings } from "./Settings";
|
||||
import { ErrorMessage } from "./ui";
|
||||
import "./styles.css";
|
||||
const navigation = [
|
||||
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||
{ id: "transactions", label: "Transactions", icon: ArrowLeftRight },
|
||||
{ id: "categories", label: "Categories", icon: FolderTree },
|
||||
{ id: "tags", label: "Tags", icon: Tags },
|
||||
{ id: "merchants", label: "Merchants", icon: Store },
|
||||
{ id: "accounts", label: "Accounts", icon: Wallet },
|
||||
{ id: "classification", label: "AI classification", icon: Sparkles },
|
||||
{ id: "settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
function App() {
|
||||
const [page, setPage] = useState(() =>
|
||||
navigation.some((n) => n.id === window.location.hash.slice(1))
|
||||
? window.location.hash.slice(1)
|
||||
: "overview",
|
||||
);
|
||||
const [state, setState] = useState<State | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [mobileNav, setMobileNav] = useState(false);
|
||||
const [filter, setFilter] = useState({ ...emptyFilter });
|
||||
const acceptState = useCallback((value: State, message?: string) => {
|
||||
setState(normalizeState(value));
|
||||
setConflict(false);
|
||||
if (message) setNotice(message);
|
||||
}, []);
|
||||
const reload = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setError("");
|
||||
try {
|
||||
acceptState(await request<State>("/api/state"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [acceptState]);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const change = () => {
|
||||
const next = window.location.hash.slice(1);
|
||||
if (navigation.some((n) => n.id === next)) setPage(next);
|
||||
};
|
||||
window.addEventListener("hashchange", change);
|
||||
return () => window.removeEventListener("hashchange", change);
|
||||
}, [reload]);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(""), 7000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
useEffect(() => {
|
||||
const connected = new URLSearchParams(window.location.search).get(
|
||||
"connected",
|
||||
);
|
||||
if (connected === "1") {
|
||||
setNotice(
|
||||
"Bank authorization completed. Your connection is available in Accounts.",
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.hash}`,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
const navigate = (next: string) => {
|
||||
setPage(next);
|
||||
window.location.hash = next;
|
||||
setMobileNav(false);
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
};
|
||||
const mutate = async (
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
message?: string,
|
||||
) => {
|
||||
if (!state) throw new Error("Load the journal before making changes.");
|
||||
const revisionless = [
|
||||
"/api/settings",
|
||||
"/api/sync",
|
||||
"/api/rebuild",
|
||||
].includes(path);
|
||||
try {
|
||||
acceptState(
|
||||
await request<State>(
|
||||
path,
|
||||
revisionless ? body : { revision: state.revision, ...body },
|
||||
),
|
||||
message,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof APIError && err.status === 409) setConflict(true);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const reconnects =
|
||||
state?.connections.filter(
|
||||
(connection) => connection.status === "reconnect_required",
|
||||
) || [];
|
||||
return (
|
||||
<div className="app">
|
||||
<a className="skip-link" href="#main-content">
|
||||
Skip to content
|
||||
</a>
|
||||
{mobileNav && (
|
||||
<button
|
||||
className="nav-backdrop"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setMobileNav(false)}
|
||||
/>
|
||||
)}
|
||||
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
|
||||
<a
|
||||
href="#overview"
|
||||
className="brand"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("overview");
|
||||
}}
|
||||
>
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<path
|
||||
d="M6 17c0-4 3-6 7-6V7c0-3 2-5 5-5s5 2 5 5v3h3v3h-5c0 7-4 11-10 11-5 0-8-3-8-7h3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle cx="19" cy="7" r="1.2" fill="#102131" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
finance<span className="brand-light">duck</span>
|
||||
<small>YOUR MONEY, CLEARLY</small>
|
||||
</span>
|
||||
</a>
|
||||
<span className="nav-label">WORKSPACE</span>
|
||||
<nav aria-label="Main navigation">
|
||||
{navigation.map(({ id, label, icon: Icon }, i) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`nav-item ${page === id ? "active" : ""} ${i === 7 ? "nav-settings" : ""}`}
|
||||
aria-current={page === id ? "page" : undefined}
|
||||
onClick={() => navigate(id)}
|
||||
>
|
||||
<Icon size={19} />
|
||||
<span>{label}</span>
|
||||
{id === "classification" && <span className="nav-ai">AI</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-bottom">
|
||||
<ShieldCheck size={19} />
|
||||
<div>
|
||||
<strong>Private by nature</strong>
|
||||
<span>Self-hosted. In your hands.</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="main-shell">
|
||||
<header className="topbar">
|
||||
<div className="breadcrumb">
|
||||
<button
|
||||
className="icon-button mobile-toggle"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => setMobileNav(true)}
|
||||
>
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<span>Workspace</span>
|
||||
<span className="breadcrumb-divider">/</span>
|
||||
<strong>{navigation.find((n) => n.id === page)?.label}</strong>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<span className="local-status">
|
||||
<span />
|
||||
Local workspace
|
||||
</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
title="Refresh journal and revision"
|
||||
aria-label="Refresh journal and revision"
|
||||
disabled={refreshing}
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
<RefreshCw size={18} className={refreshing ? "spin" : ""} />
|
||||
</button>
|
||||
<button
|
||||
className="avatar"
|
||||
title="Open privacy settings"
|
||||
aria-label="Open privacy settings"
|
||||
onClick={() => navigate("settings")}
|
||||
>
|
||||
<ShieldCheck size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
{notice && (
|
||||
<div className="toast" role="status">
|
||||
<CheckCircle2 size={18} />
|
||||
<span>{notice}</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="Dismiss notification"
|
||||
onClick={() => setNotice("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ErrorMessage error={error} />
|
||||
{conflict && (
|
||||
<div className="alert error">
|
||||
<div>
|
||||
<strong>Your journal has changed.</strong>
|
||||
<p>
|
||||
The conflicting change was not applied. Reloading closes stale
|
||||
edit forms so you can edit the latest version.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
Reload latest revision
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{reconnects.length > 0 && (
|
||||
<div className="alert warning">
|
||||
<CircleHelp size={19} />
|
||||
<span>
|
||||
{Array.from(new Set(reconnects.map((c) => c.institution))).join(
|
||||
", ",
|
||||
)}{" "}
|
||||
needs reconnection. Your existing transactions are safe.
|
||||
</span>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => navigate("accounts")}
|
||||
>
|
||||
Reconnect bank
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{state &&
|
||||
(state.status.sync_error || state.status.index_error) &&
|
||||
page !== "settings" && (
|
||||
<div className="alert warning">
|
||||
<CircleHelp size={19} />
|
||||
<span>
|
||||
{state.status.index_error
|
||||
? `Analytics needs attention: ${state.status.index_error}`
|
||||
: `Bank sync needs attention: ${state.status.sync_error}`}
|
||||
</span>
|
||||
<button
|
||||
className="button subtle"
|
||||
onClick={() => navigate("settings")}
|
||||
>
|
||||
View status
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!state ? (
|
||||
<div className="loading-block">
|
||||
{refreshing ? (
|
||||
<>
|
||||
<span className="spinner" />
|
||||
<span role="status">Opening your workspace…</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
Retry loading workspace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{page === "overview" && (
|
||||
<Overview
|
||||
data={state.data}
|
||||
revision={state.revision}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
{page === "transactions" && (
|
||||
<Transactions
|
||||
key={state.revision}
|
||||
data={state.data}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
mutate={mutate}
|
||||
/>
|
||||
)}
|
||||
{page === "categories" && (
|
||||
<Registry
|
||||
key={`categories-${state.revision}`}
|
||||
entity="category"
|
||||
data={state.data}
|
||||
mutate={mutate}
|
||||
/>
|
||||
)}
|
||||
{page === "tags" && (
|
||||
<Registry
|
||||
key={`tags-${state.revision}`}
|
||||
entity="tag"
|
||||
data={state.data}
|
||||
mutate={mutate}
|
||||
/>
|
||||
)}
|
||||
{page === "merchants" && (
|
||||
<Registry
|
||||
key={`merchants-${state.revision}`}
|
||||
entity="merchant"
|
||||
data={state.data}
|
||||
mutate={mutate}
|
||||
/>
|
||||
)}
|
||||
{page === "accounts" && (
|
||||
<Accounts
|
||||
key={state.revision}
|
||||
state={state}
|
||||
mutate={mutate}
|
||||
acceptState={acceptState}
|
||||
/>
|
||||
)}
|
||||
{page === "classification" && (
|
||||
<Classification state={state} acceptState={acceptState} />
|
||||
)}
|
||||
{page === "settings" && (
|
||||
<Settings
|
||||
key={`${state.settings.model}-${state.settings.include_amount}`}
|
||||
state={state}
|
||||
mutate={mutate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<footer>
|
||||
<span>Finance Duck</span>
|
||||
<span>Clarity without compromise.</span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ message: string }
|
||||
> {
|
||||
state = { message: "" };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { message: error.message };
|
||||
}
|
||||
render() {
|
||||
return this.state.message ? (
|
||||
<div className="fatal">
|
||||
<h1>We couldn't display this workspace</h1>
|
||||
<p>
|
||||
The server response or application needs attention. Your journal has
|
||||
not been modified by this display error.
|
||||
</p>
|
||||
<ErrorMessage error={this.state.message} />
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload workspace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user