Compare commits
8 Commits
2025.9.12
...
f7d828f669
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7d828f669 | ||
|
|
4e2f1bec0d | ||
|
|
25c7ad5901 | ||
|
|
3fac1fea17 | ||
|
|
81d7d16301 | ||
|
|
84e609a774 | ||
|
|
fb310a5953 | ||
|
|
c83386b1d5 |
1
frontend/.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dev-dist
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import { globalIgnores } from "eslint/config";
|
import { globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default tseslint.config([
|
export default tseslint.config([
|
||||||
globalIgnores(["dist"]),
|
globalIgnores(["dist", "dev-dist"]),
|
||||||
{
|
{
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
@@ -2,9 +2,35 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Leggen</title>
|
<title>Leggen</title>
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="description" content="Personal finance management application" />
|
||||||
|
<meta name="application-name" content="Leggen" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Leggen" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||||
|
<meta name="msapplication-TileColor" content="#0b74de" />
|
||||||
|
<meta name="msapplication-tap-highlight" content="no" />
|
||||||
|
|
||||||
|
<!-- Dynamic theme-color - will be updated by JavaScript -->
|
||||||
|
<meta name="theme-color" content="#0b74de" id="theme-color-meta" />
|
||||||
|
<meta name="msapplication-navbutton-color" content="#0b74de" id="ms-theme-color-meta" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" id="apple-status-bar-meta" />
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||||
|
<link rel="mask-icon" href="/favicon.svg" color="#0b74de" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
|
||||||
|
<!-- Manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
5473
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"react-day-picker": "^9.10.0",
|
"react-day-picker": "^9.10.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^3.2.0",
|
"recharts": "^3.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
@@ -40,13 +42,16 @@
|
|||||||
"@tanstack/router-vite-plugin": "^1.131.36",
|
"@tanstack/router-vite-plugin": "^1.131.36",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vite-pwa/assets-generator": "^1.0.1",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.1",
|
"typescript-eslint": "^8.39.1",
|
||||||
"vite": "^7.1.2"
|
"vite": "^7.1.2",
|
||||||
|
"vite-plugin-pwa": "^1.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
9
frontend/public/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/pwa-192x192.png"/>
|
||||||
|
<TileColor>#3B82F6</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 813 B |
@@ -1,4 +1,27 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
|
width="32" height="32"
|
||||||
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
|
viewBox="0 0 32 32"
|
||||||
|
role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">leggen — stylized italic L</title>
|
||||||
|
<desc id="desc">Square gradient background with italic white L.</desc>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0b74de"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Square background -->
|
||||||
|
<rect width="32" height="32" fill="url(#bg)" rx="4"/>
|
||||||
|
|
||||||
|
<!-- Italic L -->
|
||||||
|
<text x="11" y="22"
|
||||||
|
font-family="Inter, Roboto, Arial, sans-serif"
|
||||||
|
font-weight="700"
|
||||||
|
font-size="20"
|
||||||
|
font-style="italic"
|
||||||
|
fill="#fff">
|
||||||
|
L
|
||||||
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 769 B |
BIN
frontend/public/maskable-icon-512x512.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/pwa-64x64.png
Normal file
|
After Width: | Height: | Size: 701 B |
4
frontend/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: /sitemap.xml
|
||||||
4
frontend/pwa-assets.config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"preset": "minimal-2023",
|
||||||
|
"images": ["public/favicon.svg"]
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from "./ui/card";
|
} from "./ui/card";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import AccountsSkeleton from "./AccountsSkeleton";
|
||||||
import type { Account, Balance } from "../types/api";
|
import type { Account, Balance } from "../types/api";
|
||||||
|
|
||||||
// Helper function to get status indicator color and styles
|
// Helper function to get status indicator color and styles
|
||||||
@@ -37,23 +37,23 @@ const getStatusIndicator = (status: string) => {
|
|||||||
};
|
};
|
||||||
case "pending":
|
case "pending":
|
||||||
return {
|
return {
|
||||||
color: "bg-yellow-500",
|
color: "bg-amber-500",
|
||||||
tooltip: "Pending",
|
tooltip: "Pending",
|
||||||
};
|
};
|
||||||
case "error":
|
case "error":
|
||||||
case "failed":
|
case "failed":
|
||||||
return {
|
return {
|
||||||
color: "bg-red-500",
|
color: "bg-destructive",
|
||||||
tooltip: "Error",
|
tooltip: "Error",
|
||||||
};
|
};
|
||||||
case "inactive":
|
case "inactive":
|
||||||
return {
|
return {
|
||||||
color: "bg-gray-500",
|
color: "bg-muted-foreground",
|
||||||
tooltip: "Inactive",
|
tooltip: "Inactive",
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
color: "bg-blue-500",
|
color: "bg-primary",
|
||||||
tooltip: status,
|
tooltip: status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -114,11 +114,7 @@ export default function AccountsOverview() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (accountsLoading) {
|
if (accountsLoading) {
|
||||||
return (
|
return <AccountsSkeleton />;
|
||||||
<Card>
|
|
||||||
<LoadingSpinner message="Loading accounts..." />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountsError) {
|
if (accountsError) {
|
||||||
@@ -201,8 +197,8 @@ export default function AccountsOverview() {
|
|||||||
{uniqueBanks}
|
{uniqueBanks}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-purple-100 dark:bg-purple-900/20 rounded-full">
|
<div className="p-3 bg-muted rounded-full">
|
||||||
<Building2 className="h-6 w-6 text-purple-600" />
|
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -304,7 +300,9 @@ export default function AccountsOverview() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2 min-w-0">
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
|
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
|
||||||
{account.display_name || account.name || "Unnamed Account"}
|
{account.display_name ||
|
||||||
|
account.name ||
|
||||||
|
"Unnamed Account"}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditStart(account)}
|
onClick={() => handleEditStart(account)}
|
||||||
|
|||||||
61
frontend/src/components/AccountsSkeleton.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||||
|
|
||||||
|
export default function AccountsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Cards Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-12 w-12 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accounts List Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="p-4 sm:p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
|
<Skeleton className="h-10 w-10 sm:h-12 sm:w-12 rounded-full flex-shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||||
|
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||||
|
<Skeleton className="h-3 w-3 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
import type { ErrorInfo, ReactNode } from "react";
|
import type { ErrorInfo, ReactNode } from "react";
|
||||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { Card, CardContent } from "./ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -39,46 +42,49 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
<div className="flex items-center justify-center text-center">
|
<CardContent className="p-6">
|
||||||
<div>
|
<div className="flex items-center justify-center text-center">
|
||||||
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||||
Something went wrong
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
</h3>
|
Something went wrong
|
||||||
<p className="text-gray-600 mb-4">
|
</h3>
|
||||||
An error occurred while rendering this component. Please try
|
<p className="text-muted-foreground mb-4">
|
||||||
refreshing or check the console for more details.
|
An error occurred while rendering this component. Please try
|
||||||
</p>
|
refreshing or check the console for more details.
|
||||||
|
</p>
|
||||||
|
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-3 mb-4 text-left">
|
<Alert variant="destructive" className="mb-4 text-left">
|
||||||
<p className="text-sm font-mono text-red-800">
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<strong>Error:</strong> {this.state.error.message}
|
<AlertTitle>Error Details</AlertTitle>
|
||||||
</p>
|
<AlertDescription className="space-y-2">
|
||||||
{this.state.error.stack && (
|
<p className="text-sm font-mono">
|
||||||
<details className="mt-2">
|
<strong>Error:</strong> {this.state.error.message}
|
||||||
<summary className="text-sm text-red-600 cursor-pointer">
|
</p>
|
||||||
Stack trace
|
{this.state.error.stack && (
|
||||||
</summary>
|
<details className="mt-2">
|
||||||
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
|
<summary className="text-sm cursor-pointer">
|
||||||
{this.state.error.stack}
|
Stack trace
|
||||||
</pre>
|
</summary>
|
||||||
</details>
|
<pre className="text-xs mt-1 whitespace-pre-wrap">
|
||||||
)}
|
{this.state.error.stack}
|
||||||
</div>
|
</pre>
|
||||||
)}
|
</details>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<Button onClick={this.handleReset}>
|
||||||
onClick={this.handleReset}
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
Try Again
|
||||||
>
|
</Button>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
</div>
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card, CardContent } from "./ui/card";
|
||||||
|
|
||||||
export default function FiltersSkeleton() {
|
export default function FiltersSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow animate-pulse">
|
<Card>
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="h-6 bg-gray-200 rounded w-32"></div>
|
<Skeleton className="h-6 w-32" />
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-8 bg-gray-200 rounded w-24"></div>
|
<Skeleton className="h-8 w-24" />
|
||||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
<CardContent className="px-6 py-4 border-b border-border bg-muted/30">
|
||||||
{/* Quick Date Filters Skeleton */}
|
{/* Quick Date Filters Skeleton */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
|
<Skeleton className="h-4 w-32 mb-3" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
|
<Skeleton className="h-10 w-28 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,40 +34,40 @@ export default function FiltersSkeleton() {
|
|||||||
{/* Filter Fields Skeleton */}
|
{/* Filter Fields Skeleton */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="sm:col-span-2 lg:col-span-1">
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
<Skeleton className="h-4 w-16 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
<Skeleton className="h-4 w-16 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
<Skeleton className="h-4 w-20 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
<Skeleton className="h-4 w-16 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amount Range Filters Skeleton */}
|
{/* Amount Range Filters Skeleton */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
<Skeleton className="h-4 w-20 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
<Skeleton className="h-4 w-20 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
{/* Results Summary Skeleton */}
|
{/* Results Summary Skeleton */}
|
||||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
|
||||||
<div className="h-4 bg-gray-200 rounded w-48"></div>
|
<Skeleton className="h-4 w-48" />
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,15 +49,15 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
|
|||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{healthLoading ? (
|
{healthLoading ? (
|
||||||
<>
|
<>
|
||||||
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
<Activity className="h-4 w-4 text-muted-foreground animate-pulse" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Checking...
|
Checking...
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : healthError || healthStatus?.status !== "healthy" ? (
|
) : healthError || healthStatus?.status !== "healthy" ? (
|
||||||
<>
|
<>
|
||||||
<WifiOff className="h-4 w-4 text-red-500" />
|
<WifiOff className="h-4 w-4 text-destructive" />
|
||||||
<span className="text-sm text-red-500">Disconnected</span>
|
<span className="text-sm text-destructive">Disconnected</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoadingSpinner({
|
|
||||||
message = "Loading...",
|
|
||||||
}: LoadingSpinnerProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-2" />
|
|
||||||
<p className="text-gray-600 text-sm">{message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
TestTube,
|
TestTube,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import NotificationsSkeleton from "./NotificationsSkeleton";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -24,6 +24,7 @@ import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { Label } from "./ui/label";
|
import { Label } from "./ui/label";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -80,11 +81,7 @@ export default function Notifications() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (settingsLoading || servicesLoading) {
|
if (settingsLoading || servicesLoading) {
|
||||||
return (
|
return <NotificationsSkeleton />;
|
||||||
<Card>
|
|
||||||
<LoadingSpinner message="Loading notifications..." />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsError || servicesError) {
|
if (settingsError || servicesError) {
|
||||||
@@ -233,12 +230,10 @@ export default function Notifications() {
|
|||||||
{service.name}
|
{service.name}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center space-x-2 mt-1">
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
<span
|
<Badge
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
variant={
|
||||||
service.enabled
|
service.enabled ? "default" : "destructive"
|
||||||
? "bg-green-100 text-green-800"
|
}
|
||||||
: "bg-red-100 text-red-800"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{service.enabled ? (
|
{service.enabled ? (
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
@@ -246,18 +241,16 @@ export default function Notifications() {
|
|||||||
<AlertCircle className="h-3 w-3 mr-1" />
|
<AlertCircle className="h-3 w-3 mr-1" />
|
||||||
)}
|
)}
|
||||||
{service.enabled ? "Enabled" : "Disabled"}
|
{service.enabled ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</Badge>
|
||||||
<span
|
<Badge
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
variant={
|
||||||
service.configured
|
service.configured ? "secondary" : "outline"
|
||||||
? "bg-blue-100 text-blue-800"
|
}
|
||||||
: "bg-yellow-100 text-yellow-800"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{service.configured
|
{service.configured
|
||||||
? "Configured"
|
? "Configured"
|
||||||
: "Not Configured"}
|
: "Not Configured"}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
95
frontend/src/components/NotificationsSkeleton.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||||
|
|
||||||
|
export default function NotificationsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Test Notification Section Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-5" />
|
||||||
|
<Skeleton className="h-6 w-36" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Skeleton className="h-10 w-48" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notification Services Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-5" />
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-12 rounded-full" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notification Settings Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-5" />
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<div className="bg-muted rounded-md p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-28" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
frontend/src/components/PWAPrompts.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { X, Download, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PWAPromptProps {
|
||||||
|
onInstall?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
// Prevent the mini-infobar from appearing on mobile
|
||||||
|
e.preventDefault();
|
||||||
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||||
|
setShowPrompt(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", handler);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
|
if (outcome === "accepted") {
|
||||||
|
onInstall?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
setShowPrompt(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error installing PWA:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setShowPrompt(false);
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showPrompt || !deferredPrompt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Install Leggen
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Add to your home screen for quick access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PWAUpdatePromptProps {
|
||||||
|
updateAvailable: boolean;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptProps) {
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateAvailable) {
|
||||||
|
setShowPrompt(true);
|
||||||
|
}
|
||||||
|
}, [updateAvailable]);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
onUpdate();
|
||||||
|
setShowPrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setShowPrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showPrompt || !updateAvailable) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Update Available
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
A new version of Leggen is ready to install
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
Update Now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Link, useLocation } from "@tanstack/react-router";
|
import { Link, useLocation } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
|
||||||
Home,
|
Home,
|
||||||
List,
|
List,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Logo } from "./ui/logo";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency } from "../lib/utils";
|
import { formatCurrency } from "../lib/utils";
|
||||||
@@ -53,7 +53,7 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<CreditCard className="h-8 w-8 text-primary" />
|
<Logo size={32} />
|
||||||
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
|
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card } from "./ui/card";
|
||||||
|
|
||||||
interface TransactionSkeletonProps {
|
interface TransactionSkeletonProps {
|
||||||
rows?: number;
|
rows?: number;
|
||||||
view?: "table" | "mobile";
|
view?: "table" | "mobile";
|
||||||
@@ -11,93 +14,89 @@ export default function TransactionSkeleton({
|
|||||||
|
|
||||||
if (view === "mobile") {
|
if (view === "mobile") {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
<Card className="divide-y divide-border">
|
||||||
{skeletonRows.map((_, index) => (
|
{skeletonRows.map((_, index) => (
|
||||||
<div key={index} className="p-4 animate-pulse">
|
<div key={index} className="p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||||
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
<Skeleton className="h-4 w-3/4" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
<Skeleton className="h-3 w-1/2" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
<Skeleton className="h-3 w-2/3" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
<Skeleton className="h-3 w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
||||||
<div className="h-6 bg-gray-200 rounded w-20"></div>
|
<Skeleton className="h-6 w-20" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div>
|
<Skeleton className="h-4 w-16 ml-auto" />
|
||||||
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div>
|
<Skeleton className="h-6 w-12 ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
|
<Skeleton className="h-4 w-20" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
<Skeleton className="h-4 w-16" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div>
|
<Skeleton className="h-4 w-12" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div>
|
<Skeleton className="h-4 w-8" />
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{skeletonRows.map((_, index) => (
|
{skeletonRows.map((_, index) => (
|
||||||
<tr key={index} className="animate-pulse">
|
<tr key={index}>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||||
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
<Skeleton className="h-4 w-3/4" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
<Skeleton className="h-3 w-1/2" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
<Skeleton className="h-3 w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div>
|
<Skeleton className="h-6 w-24 ml-auto mb-1" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
<Skeleton className="h-4 w-20" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
<Skeleton className="h-3 w-16" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="h-6 bg-gray-200 rounded w-12"></div>
|
<Skeleton className="h-6 w-12" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface PieDataPoint {
|
|||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
[key: string]: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export function AccountCombobox({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const formatAccountName = (account: Account) => {
|
const formatAccountName = (account: Account) => {
|
||||||
const displayName = account.display_name || account.name || "Unnamed Account";
|
const displayName =
|
||||||
|
account.display_name || account.name || "Unnamed Account";
|
||||||
return `${displayName} (${account.institution_id})`;
|
return `${displayName} (${account.institution_id})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,7 +106,9 @@ export function AccountCombobox({
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{account.display_name || account.name || "Unnamed Account"}
|
{account.display_name ||
|
||||||
|
account.name ||
|
||||||
|
"Unnamed Account"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
44
frontend/src/components/ui/logo.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ className = "", size = 32 }: LogoProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="logo-title logo-desc"
|
||||||
|
>
|
||||||
|
<title id="logo-title">leggen — stylized italic L</title>
|
||||||
|
<desc id="logo-desc">Square gradient background with italic white L.</desc>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#0b74de" />
|
||||||
|
<stop offset="100%" stopColor="#06b6d4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Square background */}
|
||||||
|
<rect width="32" height="32" fill="url(#logo-bg)" rx="4" />
|
||||||
|
|
||||||
|
{/* Italic L */}
|
||||||
|
<text
|
||||||
|
x="11"
|
||||||
|
y="22"
|
||||||
|
fontFamily="Inter, Roboto, Arial, sans-serif"
|
||||||
|
fontWeight="700"
|
||||||
|
fontSize="20"
|
||||||
|
fontStyle="italic"
|
||||||
|
fill="#fff"
|
||||||
|
>
|
||||||
|
L
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
138
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root;
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close;
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
));
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetFooter.displayName = "SheetFooter";
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
15
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
27
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
@@ -10,6 +10,12 @@ interface ThemeContextType {
|
|||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Theme colors for different modes
|
||||||
|
const THEME_COLORS = {
|
||||||
|
light: "#0b74de", // Primary brand color
|
||||||
|
dark: "#0f0f23", // Dark background color that matches typical dark themes
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
const stored = localStorage.getItem("theme") as Theme;
|
const stored = localStorage.getItem("theme") as Theme;
|
||||||
@@ -40,6 +46,28 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Add resolved theme class
|
// Add resolved theme class
|
||||||
root.classList.add(resolvedTheme);
|
root.classList.add(resolvedTheme);
|
||||||
|
|
||||||
|
// Update theme-color meta tags for PWA status bar
|
||||||
|
const themeColor = THEME_COLORS[resolvedTheme];
|
||||||
|
|
||||||
|
// Update theme-color meta tag
|
||||||
|
const themeColorMeta = document.getElementById("theme-color-meta") as HTMLMetaElement;
|
||||||
|
if (themeColorMeta) {
|
||||||
|
themeColorMeta.content = themeColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Microsoft tile color
|
||||||
|
const msThemeColorMeta = document.getElementById("ms-theme-color-meta") as HTMLMetaElement;
|
||||||
|
if (msThemeColorMeta) {
|
||||||
|
msThemeColorMeta.content = themeColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Apple status bar style for better iOS integration
|
||||||
|
const appleStatusBarMeta = document.getElementById("apple-status-bar-meta") as HTMLMetaElement;
|
||||||
|
if (appleStatusBarMeta) {
|
||||||
|
// Use 'black-translucent' for dark theme, 'default' for light theme
|
||||||
|
appleStatusBarMeta.content = resolvedTheme === "dark" ? "black-translucent" : "default";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
updateActualTheme();
|
updateActualTheme();
|
||||||
|
|||||||
37
frontend/src/hooks/usePWA.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface PWAUpdate {
|
||||||
|
updateAvailable: boolean;
|
||||||
|
updateSW: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePWA(): PWAUpdate {
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(() => async () => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if SW registration is available
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
// Import the registerSW function
|
||||||
|
import("virtual:pwa-register").then(({ registerSW }) => {
|
||||||
|
const updateSWFunction = registerSW({
|
||||||
|
onNeedRefresh() {
|
||||||
|
setUpdateAvailable(true);
|
||||||
|
setUpdateSW(() => updateSWFunction);
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log("App ready to work offline");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// PWA not available in development mode or when disabled
|
||||||
|
console.log("PWA registration not available");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateAvailable,
|
||||||
|
updateSW,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 222.2 47.4% 11.2%;
|
--primary: 219 91% 46%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 189 94% 43%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 210 40% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 210 40% 96.1%;
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 210 40% 98%;
|
--primary: 219 91% 46%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 189 94% 43%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 217.2 32.6% 17.5%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|||||||
@@ -42,10 +42,9 @@ export const apiClient = {
|
|||||||
id: string,
|
id: string,
|
||||||
updates: AccountUpdate,
|
updates: AccountUpdate,
|
||||||
): Promise<{ id: string; display_name?: string }> => {
|
): Promise<{ id: string; display_name?: string }> => {
|
||||||
const response = await api.put<ApiResponse<{ id: string; display_name?: string }>>(
|
const response = await api.put<
|
||||||
`/accounts/${id}`,
|
ApiResponse<{ id: string; display_name?: string }>
|
||||||
updates,
|
>(`/accounts/${id}`, updates);
|
||||||
);
|
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,25 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Sidebar from "../components/Sidebar";
|
import Sidebar from "../components/Sidebar";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
|
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||||
|
import { usePWA } from "../hooks/usePWA";
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const { updateAvailable, updateSW } = usePWA();
|
||||||
|
|
||||||
|
const handlePWAInstall = () => {
|
||||||
|
console.log("PWA installed successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePWAUpdate = async () => {
|
||||||
|
try {
|
||||||
|
await updateSW();
|
||||||
|
console.log("PWA updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating PWA:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
@@ -24,6 +40,13 @@ function RootLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* PWA Prompts */}
|
||||||
|
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
||||||
|
<PWAUpdatePrompt
|
||||||
|
updateAvailable={updateAvailable}
|
||||||
|
onUpdate={handlePWAUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
|
|||||||
@@ -1,10 +1,88 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [TanStackRouterVite(), react()],
|
plugins: [
|
||||||
|
TanStackRouterVite(),
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["favicon.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "robots.txt"],
|
||||||
|
manifest: {
|
||||||
|
name: "Leggen",
|
||||||
|
short_name: "Leggen",
|
||||||
|
description: "Personal finance management application",
|
||||||
|
theme_color: "#0b74de",
|
||||||
|
background_color: "#ffffff",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait",
|
||||||
|
scope: "/",
|
||||||
|
start_url: "/",
|
||||||
|
categories: ["finance", "productivity"],
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
name: "Transactions",
|
||||||
|
short_name: "Transactions",
|
||||||
|
description: "View and manage transactions",
|
||||||
|
url: "/transactions",
|
||||||
|
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Analytics",
|
||||||
|
short_name: "Analytics",
|
||||||
|
description: "View financial analytics",
|
||||||
|
url: "/analytics",
|
||||||
|
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "pwa-64x64.png",
|
||||||
|
sizes: "64x64",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "pwa-192x192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "pwa-512x512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "maskable-icon-512x512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/.*\/api\//,
|
||||||
|
handler: "NetworkFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "api-cache",
|
||||||
|
networkTimeoutSeconds: 10,
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": "/src",
|
"@": "/src",
|
||||||
|
|||||||