abgeordnetenwatch PWA + backend
feature-based React PWA with Hono backend: - feed from abgeordnetenwatch.de API (polls by topic + politician) - follow topics, search and follow politicians - geo-based politician discovery via Nominatim - push notifications for new polls via web-push - service worker with offline caching - deploy to Uberspace 8 (systemd, PostgreSQL, web backend proxy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.DS_Store
|
||||
src/routeTree.gen.ts
|
||||
server/drizzle/
|
||||
*.log
|
||||
2
.mise.toml
Normal file
2
.mise.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
bun = "1.3.10"
|
||||
87
AGENTS.md
Normal file
87
AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Agent Instructions
|
||||
|
||||
These instructions are authoritative for work in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow Bundestag/Landtag topics and politicians, displaying a personalized feed of votes with push notifications for new votes. The PWA queries the Abgeordnetenwatch API directly for browsing; the backend polls for new votes and sends Web Push notifications to subscribed devices.
|
||||
|
||||
## Stack — PWA (root)
|
||||
|
||||
| Layer | Tool |
|
||||
|---|---|
|
||||
| Runtime | Bun |
|
||||
| Build | Vite + TanStack Router Plugin + vite-plugin-pwa (injectManifest) |
|
||||
| UI | React 19 + shadcn/ui + Tailwind CSS v4 |
|
||||
| Routing | TanStack Router (file-based, `src/routes/`) |
|
||||
| State | Zustand (UI), localStorage (Follows) |
|
||||
| Validation | Zod |
|
||||
| Code Quality | Biome + simple-git-hooks + lint-staged |
|
||||
| Testing | Vitest + Testing Library |
|
||||
|
||||
## Stack — Backend (`server/`)
|
||||
|
||||
| Layer | Tool |
|
||||
|---|---|
|
||||
| Runtime | Node 22 (production via tsx), Bun (development) |
|
||||
| Framework | Hono + @hono/node-server |
|
||||
| Database | PostgreSQL + Drizzle ORM |
|
||||
| Job Queue | pg-boss (cron: poll-checker every 15 min) |
|
||||
| Push | web-push (VAPID) |
|
||||
| Validation | Zod |
|
||||
| Testing | Vitest |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/ PWA source
|
||||
├── features/ Feature modules (feed, topics, politicians, location, settings)
|
||||
│ └── <feature>/ components/, hooks/, lib/, index.ts
|
||||
├── shared/ Shared code
|
||||
│ ├── components/ui/ shadcn components
|
||||
│ ├── hooks/ use-device-id, use-follows, use-push, use-pwa-update
|
||||
│ └── lib/ aw-api, constants, push-client, utils
|
||||
├── routes/ TanStack Router file-based routes
|
||||
├── sw.ts Custom service worker (precache + push handlers)
|
||||
├── app.tsx RouterProvider
|
||||
├── app.css Tailwind v4 + shadcn theme
|
||||
└── main.tsx Entry point
|
||||
|
||||
server/ Backend source
|
||||
├── src/
|
||||
│ ├── features/push/ Push subscription routes + service
|
||||
│ ├── shared/
|
||||
│ │ ├── db/ Drizzle client + schema (push_subscriptions, device_follows, seen_polls, politician_mandates)
|
||||
│ │ ├── jobs/ pg-boss client + poll-checker cron
|
||||
│ │ └── lib/ env validation, AW API client (server-side), web-push helper
|
||||
│ ├── app.ts Hono app assembly
|
||||
│ └── index.ts Entry point
|
||||
├── drizzle.config.ts
|
||||
├── package.json
|
||||
└── .env.example DATABASE_URL, PORT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
- PWA: static files deploy to Uberspace at `/var/www/virtual/<user>/html/agw/`
|
||||
- Backend: deployed as a supervisord service on Uberspace at `~/services/agw-backend/`
|
||||
- Backend API is reverse-proxied at `/agw-api/` via `uberspace web backend`
|
||||
- `./deploy.sh` builds and deploys both PWA and backend
|
||||
- Apache `.htaccess` handles SPA routing
|
||||
|
||||
## Commands — PWA (root)
|
||||
|
||||
- `bun dev` — start dev server
|
||||
- `bun run build` — production build
|
||||
- `bun run test` — run tests
|
||||
- `bun run lint` — biome check
|
||||
- `bun run lint:fix` — biome auto-fix
|
||||
|
||||
## Commands — Backend (`server/`)
|
||||
|
||||
- `bun dev` — start with file watching
|
||||
- `bun start` — start with Node + tsx (production)
|
||||
- `bun run db:generate` — generate Drizzle migrations
|
||||
- `bun run db:migrate` — run Drizzle migrations
|
||||
- `bun run test` — run tests
|
||||
- `bun run lint` — biome check
|
||||
30
biome.json
Normal file
30
biome.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 120
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn",
|
||||
"noUnusedVariables": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist/", "node_modules/", "src/routeTree.gen.ts", "src/shared/components/ui/", ".claude/"]
|
||||
}
|
||||
}
|
||||
23
components.json
Normal file
23
components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/shared/components",
|
||||
"utils": "@/shared/lib/utils",
|
||||
"ui": "@/shared/components/ui",
|
||||
"lib": "@/shared/lib",
|
||||
"hooks": "@/shared/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
84
deploy.sh
Executable file
84
deploy.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — build and deploy PWA + backend to Uberspace 8
|
||||
# Usage: ./deploy.sh [uberspace-user@host] (default: serve)
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${1:-serve}"
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# reuse a single SSH connection to avoid rate limiting
|
||||
SSH_SOCK="/tmp/deploy-ssh-$$"
|
||||
ssh -fNM -o ControlMaster=yes -S "$SSH_SOCK" "$REMOTE"
|
||||
trap 'ssh -S "$SSH_SOCK" -O exit "$REMOTE" 2>/dev/null' EXIT
|
||||
r() { ssh -S "$SSH_SOCK" "$REMOTE" "$@"; }
|
||||
|
||||
REMOTE_USER=$(r 'echo $USER')
|
||||
REMOTE_AGW_DIR="/var/www/virtual/$REMOTE_USER/html/agw"
|
||||
REMOTE_SERVICE_DIR="/home/$REMOTE_USER/services/agw-backend"
|
||||
SERVICE_NAME="agw-backend"
|
||||
BACKEND_PORT=3002
|
||||
|
||||
# --- PWA ---
|
||||
|
||||
echo "==> Building PWA"
|
||||
cd "$LOCAL_DIR"
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
echo "==> Deploying static files to $REMOTE:$REMOTE_AGW_DIR"
|
||||
r "mkdir -p $REMOTE_AGW_DIR"
|
||||
rsync -avz --delete -e "ssh -S $SSH_SOCK" \
|
||||
"$LOCAL_DIR/dist/" "$REMOTE:$REMOTE_AGW_DIR/"
|
||||
|
||||
# --- Backend ---
|
||||
|
||||
echo "==> Deploying backend to $REMOTE:$REMOTE_SERVICE_DIR"
|
||||
r "mkdir -p $REMOTE_SERVICE_DIR"
|
||||
rsync -avz --delete -e "ssh -S $SSH_SOCK" \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='.env' \
|
||||
"$LOCAL_DIR/server/" "$REMOTE:$REMOTE_SERVICE_DIR/"
|
||||
|
||||
echo "==> Installing backend dependencies"
|
||||
r "cd $REMOTE_SERVICE_DIR && npm install"
|
||||
|
||||
echo "==> Checking .env exists"
|
||||
r "test -f $REMOTE_SERVICE_DIR/.env || { echo 'ERROR: $REMOTE_SERVICE_DIR/.env not found — create it first'; exit 1; }"
|
||||
|
||||
echo "==> Running database migrations"
|
||||
r "cd $REMOTE_SERVICE_DIR && set -a && source .env && set +a && npx drizzle-kit migrate"
|
||||
|
||||
# --- systemd user service (Uberspace 8) ---
|
||||
|
||||
echo "==> Configuring systemd user service"
|
||||
r "mkdir -p ~/.config/systemd/user && cat > ~/.config/systemd/user/$SERVICE_NAME.service << EOF
|
||||
[Unit]
|
||||
Description=Abgeordnetenwatch Backend
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$REMOTE_SERVICE_DIR
|
||||
EnvironmentFile=$REMOTE_SERVICE_DIR/.env
|
||||
ExecStart=$REMOTE_SERVICE_DIR/node_modules/.bin/tsx src/index.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF"
|
||||
|
||||
echo "==> Restarting backend service"
|
||||
r "systemctl --user daemon-reload && systemctl --user enable $SERVICE_NAME && systemctl --user restart $SERVICE_NAME"
|
||||
sleep 2
|
||||
r "systemctl --user status $SERVICE_NAME --no-pager" || true
|
||||
|
||||
# --- Web backend (reverse proxy) ---
|
||||
|
||||
echo "==> Configuring web backend for /agw-api/"
|
||||
r "uberspace web backend add /agw-api PORT $BACKEND_PORT --remove-prefix --force --wait" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
echo " PWA: https://$REMOTE_USER.uber.space/agw/"
|
||||
echo " Backend: https://$REMOTE_USER.uber.space/agw-api/"
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1d4ed8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/agw/icons/icon.svg" />
|
||||
<title>Abgeordnetenwatch</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
package.json
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "abgeordnetenwatch-pwa",
|
||||
"version": "2026.03.01",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --fix .",
|
||||
"prepare": "simple-git-hooks"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.120.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@tanstack/router-plugin": "^1.120.3",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"simple-git-hooks": "^2.11.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^3.2.1",
|
||||
"workbox-core": "^7.4.0",
|
||||
"workbox-expiration": "^7.4.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "bunx lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,json,css}": ["biome check --fix --no-errors-on-unmatched"]
|
||||
}
|
||||
}
|
||||
9
public/.htaccess
Normal file
9
public/.htaccess
Normal file
@@ -0,0 +1,9 @@
|
||||
RewriteEngine On
|
||||
RewriteBase /agw/
|
||||
|
||||
# If the requested file or directory exists, serve it directly
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
# Otherwise, rewrite to index.html for SPA routing
|
||||
RewriteRule ^ index.html [L]
|
||||
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
17
public/icons/icon.svg
Normal file
17
public/icons/icon.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<rect width="100" height="100" rx="22" fill="#1d4ed8"/>
|
||||
<!-- Parliament building simplified -->
|
||||
<rect x="20" y="60" width="60" height="20" fill="white" opacity="0.95"/>
|
||||
<rect x="25" y="45" width="50" height="18" fill="white" opacity="0.9"/>
|
||||
<!-- Columns -->
|
||||
<rect x="28" y="47" width="6" height="16" fill="#1d4ed8"/>
|
||||
<rect x="38" y="47" width="6" height="16" fill="#1d4ed8"/>
|
||||
<rect x="48" y="47" width="6" height="16" fill="#1d4ed8"/>
|
||||
<rect x="58" y="47" width="6" height="16" fill="#1d4ed8"/>
|
||||
<!-- Roof / pediment -->
|
||||
<polygon points="22,45 50,28 78,45" fill="white" opacity="0.95"/>
|
||||
<!-- Dome -->
|
||||
<circle cx="50" cy="27" r="8" fill="white" opacity="0.85"/>
|
||||
<!-- Eagle / emblem hint -->
|
||||
<rect x="44" y="62" width="12" height="16" fill="#1d4ed8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 873 B |
110
src/app.css
Normal file
110
src/app.css
Normal file
@@ -0,0 +1,110 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.451 0.211 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.451 0.211 262.881);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.585 0.233 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.585 0.233 262.881);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
23
src/app.tsx
Normal file
23
src/app.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
basepath: "/agw",
|
||||
defaultErrorComponent: ({ error }) => (
|
||||
<div style={{ padding: 20, color: "red" }}>
|
||||
<h2>Error</h2>
|
||||
<pre>{error instanceof Error ? error.message : String(error)}</pre>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
8
src/features/feed/components/feed-empty.tsx
Normal file
8
src/features/feed/components/feed-empty.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export function FeedEmpty() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground px-6 text-center">
|
||||
<p className="text-lg font-medium">Your feed is empty</p>
|
||||
<p className="text-sm mt-2">Follow topics or politicians in the other tabs to see polls here.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
src/features/feed/components/feed-item.tsx
Normal file
53
src/features/feed/components/feed-item.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import type { FeedItem as FeedItemType } from "../lib/assemble-feed"
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })
|
||||
}
|
||||
|
||||
export function FeedItemCard({ item }: { item: FeedItemType }) {
|
||||
return (
|
||||
<article className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-base font-medium leading-snug hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<h2 className="text-base font-medium leading-snug">{item.title}</h2>
|
||||
)}
|
||||
{item.date && (
|
||||
<time dateTime={item.date} className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||
{formatDate(item.date)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
{item.topics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2" aria-label="Topics">
|
||||
{item.topics.map((topic) =>
|
||||
topic.url ? (
|
||||
<a key={topic.label} href={topic.url} target="_blank" rel="noopener noreferrer">
|
||||
<Badge variant="secondary" className="hover:bg-accent-foreground/10 cursor-pointer">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Badge key={topic.label} variant="secondary">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">{item.source}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
15
src/features/feed/components/feed-list.tsx
Normal file
15
src/features/feed/components/feed-list.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FeedItem } from "../lib/assemble-feed"
|
||||
import { FeedEmpty } from "./feed-empty"
|
||||
import { FeedItemCard } from "./feed-item"
|
||||
|
||||
export function FeedList({ items }: { items: FeedItem[] }) {
|
||||
if (items.length === 0) return <FeedEmpty />
|
||||
|
||||
return (
|
||||
<main className="divide-y divide-border">
|
||||
{items.map((item) => (
|
||||
<FeedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
36
src/features/feed/hooks/use-feed.ts
Normal file
36
src/features/feed/hooks/use-feed.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { type FeedItem, assembleFeed } from "../lib/assemble-feed"
|
||||
|
||||
export function useFeed() {
|
||||
const { follows } = useFollows()
|
||||
const [items, setItems] = useState<FeedItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const topicIDs = useMemo(() => follows.filter((f) => f.type === "topic").map((f) => f.entity_id), [follows])
|
||||
const politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (topicIDs.length === 0 && politicianIDs.length === 0) {
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const feed = await assembleFeed(topicIDs, politicianIDs)
|
||||
setItems(feed)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [topicIDs, politicianIDs])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
return { items, loading, error, refresh }
|
||||
}
|
||||
2
src/features/feed/index.ts
Normal file
2
src/features/feed/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FeedList } from "./components/feed-list"
|
||||
export { useFeed } from "./hooks/use-feed"
|
||||
3
src/features/location/index.ts
Normal file
3
src/features/location/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { loadCachedResult, clearGeoCache, detectFromCoords } from "./lib/geo"
|
||||
export type { GeoResult } from "./lib/geo"
|
||||
export { getPartyMeta } from "./lib/parties"
|
||||
160
src/features/politicians/components/politician-search.tsx
Normal file
160
src/features/politicians/components/politician-search.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { type GeoResult, loadCachedResult } from "../../location/lib/geo"
|
||||
import { getPartyMeta } from "../../location/lib/parties"
|
||||
|
||||
interface PartyGroup {
|
||||
partyLabel: string
|
||||
members: MandateWithPolitician[]
|
||||
}
|
||||
|
||||
/** Extract party/fraction label for grouping. Prefers party, falls back to current fraction. */
|
||||
function partyLabel(m: MandateWithPolitician): string {
|
||||
if (m.party?.label) return m.party.label
|
||||
// fraction label is e.g. "CDU/CSU (Bundestag 2025 - 2029)" — strip the parliament suffix
|
||||
const current = m.fraction_membership?.find((f) => !f.valid_until)
|
||||
if (current) return current.fraction.label.replace(/\s*\([^)]+\)\s*$/, "")
|
||||
return "parteilos"
|
||||
}
|
||||
|
||||
function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
|
||||
const map = new Map<string, MandateWithPolitician[]>()
|
||||
for (const m of mandates) {
|
||||
const key = partyLabel(m)
|
||||
const list = map.get(key)
|
||||
if (list) {
|
||||
list.push(m)
|
||||
} else {
|
||||
map.set(key, [m])
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.map(([partyLabel, members]) => ({
|
||||
partyLabel,
|
||||
members: members.sort((a, b) => a.politician.label.localeCompare(b.politician.label)),
|
||||
}))
|
||||
}
|
||||
|
||||
function mandateFunction(m: MandateWithPolitician): string | null {
|
||||
const won = m.electoral_data?.mandate_won
|
||||
const constituency = m.electoral_data?.constituency?.label
|
||||
if (!won) return null
|
||||
if (won === "constituency" && constituency) {
|
||||
const clean = constituency.replace(/\s*\([^)]+\)\s*$/, "")
|
||||
return `Wahlkreis ${clean}`
|
||||
}
|
||||
if (won === "list") return "Landesliste"
|
||||
if (won === "moved_up") return "Nachgerückt"
|
||||
return won
|
||||
}
|
||||
|
||||
export function PoliticianSearch() {
|
||||
const [result, setResult] = useState<GeoResult | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const { isFollowing, follow, unfollow } = useFollows()
|
||||
|
||||
useEffect(() => {
|
||||
const cached = loadCachedResult()
|
||||
if (cached) setResult(cached)
|
||||
}, [])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!result) return []
|
||||
const filtered = search
|
||||
? result.mandates.filter((m) => m.politician.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: result.mandates
|
||||
return groupByParty(filtered)
|
||||
}, [result, search])
|
||||
|
||||
if (!result || result.mandates.length === 0) {
|
||||
return (
|
||||
<main className="p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
No parliament members loaded yet. Detect your location in Settings first.
|
||||
</p>
|
||||
<Link to="/settings" className="text-primary text-sm underline">
|
||||
Go to Settings
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="p-4 border-b border-border">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter by name…"
|
||||
aria-label="Filter representatives"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{groups.map((group) => {
|
||||
const meta = getPartyMeta(group.partyLabel)
|
||||
return (
|
||||
<Card key={group.partyLabel}>
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border"
|
||||
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[10px] font-bold text-white shrink-0"
|
||||
style={{ backgroundColor: meta.color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{meta.short.slice(0, 3)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{group.partyLabel}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{group.members.length}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{group.members.map((m) => {
|
||||
const followed = isFollowing("politician", m.politician.id)
|
||||
const fn = mandateFunction(m)
|
||||
return (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center justify-between pl-4 pr-4 py-2.5"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: meta.color }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{m.politician.label}</p>
|
||||
{fn && <p className="text-xs text-muted-foreground mt-0.5">{fn}</p>}
|
||||
</div>
|
||||
<Button
|
||||
variant={followed ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
followed
|
||||
? unfollow("politician", m.politician.id)
|
||||
: follow("politician", m.politician.id, m.politician.label)
|
||||
}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `Unfollow ${m.politician.label}` : `Follow ${m.politician.label}`}
|
||||
className="ml-4 shrink-0 rounded-full"
|
||||
>
|
||||
{followed ? "Following" : "Follow"}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{groups.length === 0 && search && (
|
||||
<p className="text-center text-sm text-muted-foreground py-6">No members matching "{search}"</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
1
src/features/politicians/index.ts
Normal file
1
src/features/politicians/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PoliticianSearch } from "./components/politician-search"
|
||||
105
src/features/settings/components/notification-guide.tsx
Normal file
105
src/features/settings/components/notification-guide.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
interface NotificationGuideProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function NotificationGuide({ onBack }: NotificationGuideProps) {
|
||||
return (
|
||||
<main className="p-4">
|
||||
<button type="button" onClick={onBack} className="flex items-center gap-1 text-primary text-sm mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Einstellungen
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-2">Benachrichtigungen einrichten</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Push-Benachrichtigungen funktionieren auf dem iPhone nur, wenn die App zum Homescreen hinzugefügt wurde.
|
||||
</p>
|
||||
|
||||
<ol className="space-y-6">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Teilen-Menü öffnen</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Tippe auf das Teilen-Symbol{" "}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="inline w-4 h-4 align-text-bottom"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>{" "}
|
||||
in der Safari-Leiste unten.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Zum Home-Bildschirm</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Scrolle im Menü nach unten und wähle{" "}
|
||||
<span className="font-medium text-foreground">Zum Home-Bildschirm</span>.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">App hinzufügen</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Bestätige mit <span className="font-medium text-foreground">Hinzufügen</span>. Die App erscheint als Icon
|
||||
auf deinem Homescreen.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
|
||||
4
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">App öffnen & Benachrichtigungen aktivieren</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Öffne die App vom Homescreen aus und aktiviere die Benachrichtigungen in den Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="mt-8">
|
||||
<Button variant="outline" onClick={onBack} className="w-full">
|
||||
Zurück zu den Einstellungen
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
291
src/features/settings/components/settings-page.tsx
Normal file
291
src/features/settings/components/settings-page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Switch } from "@/shared/components/ui/switch"
|
||||
import { useDeviceId } from "@/shared/hooks/use-device-id"
|
||||
import { usePush } from "@/shared/hooks/use-push"
|
||||
import { usePwaUpdate } from "@/shared/hooks/use-pwa-update"
|
||||
import { VAPID_PUBLIC_KEY } from "@/shared/lib/constants"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type GeoResult, clearGeoCache, detectFromCoords, loadCachedResult } from "../../location/lib/geo"
|
||||
import { NotificationGuide } from "./notification-guide"
|
||||
|
||||
function formatCacheAge(cachedAt: number): string {
|
||||
const minutes = Math.floor((Date.now() - cachedAt) / 60_000)
|
||||
if (minutes < 1) return "gerade eben"
|
||||
if (minutes < 60) return `vor ${minutes} Min.`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `vor ${hours} Std.`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `vor ${days} T.`
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
("standalone" in navigator && (navigator as { standalone?: boolean }).standalone === true)
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const deviceId = useDeviceId()
|
||||
const { needRefresh, checkForUpdate, applyUpdate } = usePwaUpdate()
|
||||
const push = usePush()
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<GeoResult | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [showGuide, setShowGuide] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const cached = loadCachedResult()
|
||||
if (cached) setResult(cached)
|
||||
}, [])
|
||||
|
||||
const detect = useCallback((skipCache: boolean) => {
|
||||
if (!navigator.geolocation) {
|
||||
setErrorMsg("Standortbestimmung wird nicht unterstützt")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setErrorMsg(null)
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
try {
|
||||
const r = await detectFromCoords(pos.coords.latitude, pos.coords.longitude, skipCache)
|
||||
setResult(r)
|
||||
} catch (e) {
|
||||
setErrorMsg(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setErrorMsg(err.message)
|
||||
setLoading(false)
|
||||
},
|
||||
)
|
||||
}, [])
|
||||
|
||||
function handleClearCache() {
|
||||
clearGeoCache()
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
setChecking(true)
|
||||
try {
|
||||
await checkForUpdate()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasLocation = result && result.mandates.length > 0
|
||||
const standalone = isStandalone()
|
||||
|
||||
if (showGuide) {
|
||||
return <NotificationGuide onBack={() => setShowGuide(false)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-4 space-y-6">
|
||||
{/* --- Notifications --- */}
|
||||
{VAPID_PUBLIC_KEY && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">
|
||||
Benachrichtigungen
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{push.permission === "denied" ? (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-destructive">
|
||||
Benachrichtigungen sind blockiert. Bitte in den Systemeinstellungen aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<label htmlFor="push-toggle" className="text-sm font-normal">
|
||||
Push-Benachrichtigungen
|
||||
</label>
|
||||
<Switch
|
||||
id="push-toggle"
|
||||
checked={push.subscribed}
|
||||
disabled={push.loading}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) push.subscribe()
|
||||
else push.unsubscribe()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!standalone && (
|
||||
<>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide(true)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 text-left"
|
||||
>
|
||||
<span className="text-sm">Einrichtung auf dem iPhone</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Location --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Standort</h2>
|
||||
|
||||
{hasLocation && result.bundesland ? (
|
||||
<Card className="mb-3">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Bundesland</span>
|
||||
<span className="text-sm text-muted-foreground">{result.bundesland}</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.landtag_label && (
|
||||
<>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Landtag</span>
|
||||
<span className="text-sm text-muted-foreground">{result.landtag_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Abgeordnete geladen</span>
|
||||
<span className="text-sm text-muted-foreground">{result.mandates.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.cachedAt && (
|
||||
<>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Zwischengespeichert</span>
|
||||
<span className="text-sm text-muted-foreground">{formatCacheAge(result.cachedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading && (
|
||||
<output className="flex items-center justify-center h-16 mb-3" aria-label="Standort wird bestimmt">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">{hasLocation ? "Aktualisiere…" : "Erkenne…"}</span>
|
||||
</output>
|
||||
)}
|
||||
|
||||
{errorMsg && (
|
||||
<div className="mb-3 p-3 bg-destructive/10 rounded-lg text-destructive text-sm" role="alert">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{!hasLocation && !loading && (
|
||||
<Button onClick={() => detect(false)} className="w-full">
|
||||
Standort erkennen
|
||||
</Button>
|
||||
)}
|
||||
{hasLocation && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => detect(true)} disabled={loading} className="w-full">
|
||||
Abgeordnete neu laden
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleClearCache} disabled={loading} className="w-full">
|
||||
Cache löschen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- App Update --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">App-Update</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{needRefresh ? (
|
||||
<>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Neue Version verfügbar</span>
|
||||
<Button size="sm" onClick={applyUpdate}>
|
||||
Jetzt aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">App ist aktuell</span>
|
||||
<Button variant="outline" size="xs" onClick={handleCheckUpdate} disabled={checking}>
|
||||
{checking ? "Prüfe…" : "Prüfen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* --- About --- */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Info</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Datenquelle</span>
|
||||
<a
|
||||
href="https://www.abgeordnetenwatch.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary"
|
||||
>
|
||||
abgeordnetenwatch.de
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border mx-4" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Geräte-ID</span>
|
||||
<span className="text-xs text-muted-foreground font-mono max-w-[50%] truncate">{deviceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
1
src/features/settings/index.ts
Normal file
1
src/features/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SettingsPage } from "./components/settings-page"
|
||||
60
src/features/topics/components/topic-list.tsx
Normal file
60
src/features/topics/components/topic-list.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { useState } from "react"
|
||||
import { useTopics } from "../hooks/use-topics"
|
||||
|
||||
export function TopicList() {
|
||||
const { topics, loading, error } = useTopics()
|
||||
const { isFollowing, follow, unfollow } = useFollows()
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const filtered = topics.filter((t) => t.label.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="p-4 border-b border-border">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search topics…"
|
||||
aria-label="Search topics"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<output className="flex items-center justify-center h-48" aria-label="Loading topics">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</output>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-border">
|
||||
{filtered.map((topic) => {
|
||||
const followed = isFollowing("topic", topic.id)
|
||||
return (
|
||||
<li key={topic.id} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{topic.label}</span>
|
||||
<Button
|
||||
variant={followed ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
|
||||
aria-pressed={followed}
|
||||
aria-label={followed ? `Unfollow ${topic.label}` : `Follow ${topic.label}`}
|
||||
className="ml-4 shrink-0 rounded-full"
|
||||
>
|
||||
{followed ? "Following" : "Follow"}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
18
src/features/topics/hooks/use-topics.ts
Normal file
18
src/features/topics/hooks/use-topics.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type Topic, fetchTopics } from "@/shared/lib/aw-api"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useTopics() {
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchTopics()
|
||||
.then(setTopics)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return { topics, loading, error }
|
||||
}
|
||||
1
src/features/topics/index.ts
Normal file
1
src/features/topics/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TopicList } from "./components/topic-list"
|
||||
11
src/main.tsx
Normal file
11
src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "@/app.css"
|
||||
import { App } from "@/app"
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: root element always exists in index.html
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
88
src/routes/__root.tsx
Normal file
88
src/routes/__root.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Link, Outlet, createRootRoute, useMatches } from "@tanstack/react-router"
|
||||
|
||||
interface TabDef {
|
||||
to: string
|
||||
label: string
|
||||
icon: string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{
|
||||
to: "/feed",
|
||||
label: "Feed",
|
||||
ariaLabel: "Feed",
|
||||
icon: "M3 7h18M3 12h18M3 17h18",
|
||||
},
|
||||
{
|
||||
to: "/topics",
|
||||
label: "Topics",
|
||||
ariaLabel: "Topics",
|
||||
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
|
||||
},
|
||||
{
|
||||
to: "/politicians",
|
||||
label: "People",
|
||||
ariaLabel: "Politicians",
|
||||
icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
},
|
||||
{
|
||||
to: "/settings",
|
||||
label: "Settings",
|
||||
ariaLabel: "Settings",
|
||||
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
},
|
||||
]
|
||||
|
||||
function RootComponent() {
|
||||
const matches = useMatches()
|
||||
const currentPath = matches.at(-1)?.pathname ?? "/feed"
|
||||
const currentTab = TABS.find((t) => currentPath.endsWith(t.to)) ?? TABS[0]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
||||
<header className="flex items-center px-4 py-3 bg-card border-b border-border shadow-sm safe-area-top">
|
||||
<h1 className="text-base font-semibold text-card-foreground">Abgeordnetenwatch — {currentTab.label}</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
<nav className="flex bg-card border-t border-border safe-area-bottom" role="tablist" aria-label="Main navigation">
|
||||
{TABS.map((tab) => {
|
||||
const active = currentPath.endsWith(tab.to)
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-label={tab.ariaLabel}
|
||||
className={`flex flex-col items-center justify-center flex-1 py-2 gap-0.5 transition-colors no-underline ${
|
||||
active ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={active ? 2.5 : 1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={tab.icon} />
|
||||
</svg>
|
||||
<span className="text-[10px] font-medium">{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
29
src/routes/feed.tsx
Normal file
29
src/routes/feed.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { FeedList, useFeed } from "@/features/feed"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
function FeedPage() {
|
||||
const { items, loading, error } = useFeed()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<output className="flex items-center justify-center h-48" aria-label="Loading feed">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</output>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-destructive" role="alert">
|
||||
<p className="font-semibold">Error loading feed</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <FeedList items={items} />
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/feed")({
|
||||
component: FeedPage,
|
||||
})
|
||||
5
src/routes/index.tsx
Normal file
5
src/routes/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: () => <Navigate to="/feed" />,
|
||||
})
|
||||
6
src/routes/politicians.tsx
Normal file
6
src/routes/politicians.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { PoliticianSearch } from "@/features/politicians"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/politicians")({
|
||||
component: PoliticianSearch,
|
||||
})
|
||||
6
src/routes/settings.tsx
Normal file
6
src/routes/settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SettingsPage } from "@/features/settings"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsPage,
|
||||
})
|
||||
6
src/routes/topics.tsx
Normal file
6
src/routes/topics.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TopicList } from "@/features/topics"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/topics")({
|
||||
component: TopicList,
|
||||
})
|
||||
48
src/shared/components/ui/badge.tsx
Normal file
48
src/shared/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
src/shared/components/ui/button.tsx
Normal file
64
src/shared/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
src/shared/components/ui/card.tsx
Normal file
92
src/shared/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
21
src/shared/components/ui/input.tsx
Normal file
21
src/shared/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
26
src/shared/components/ui/separator.tsx
Normal file
26
src/shared/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
33
src/shared/components/ui/switch.tsx
Normal file
33
src/shared/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
33
src/shared/hooks/use-device-id.ts
Normal file
33
src/shared/hooks/use-device-id.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { STORAGE_KEYS } from "@/shared/lib/constants"
|
||||
import { useState } from "react"
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
function getOrCreateDeviceId(): string {
|
||||
let id = localStorage.getItem(STORAGE_KEYS.deviceId)
|
||||
if (!id) {
|
||||
id = generateUUID()
|
||||
localStorage.setItem(STORAGE_KEYS.deviceId, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
let _cached: string | null = null
|
||||
|
||||
export function useDeviceId(): string {
|
||||
const [deviceId] = useState<string>(() => {
|
||||
if (_cached) return _cached
|
||||
_cached = getOrCreateDeviceId()
|
||||
return _cached
|
||||
})
|
||||
return deviceId
|
||||
}
|
||||
74
src/shared/hooks/use-follows.test.ts
Normal file
74
src/shared/hooks/use-follows.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { STORAGE_KEYS } from "@/shared/lib/constants"
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { _resetFollowsCache, useFollows } from "./use-follows"
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
_resetFollowsCache()
|
||||
})
|
||||
|
||||
describe("useFollows", () => {
|
||||
it("starts with empty follows", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
expect(result.current.follows).toEqual([])
|
||||
})
|
||||
|
||||
it("can follow a topic", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
})
|
||||
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 1, label: "Umwelt" }])
|
||||
expect(result.current.isFollowing("topic", 1)).toBe(true)
|
||||
})
|
||||
|
||||
it("can follow a politician", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("politician", 42, "Angela Merkel")
|
||||
})
|
||||
expect(result.current.isFollowing("politician", 42)).toBe(true)
|
||||
expect(result.current.isFollowing("topic", 42)).toBe(false)
|
||||
})
|
||||
|
||||
it("can unfollow", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
result.current.follow("topic", 2, "Bildung")
|
||||
})
|
||||
act(() => {
|
||||
result.current.unfollow("topic", 1)
|
||||
})
|
||||
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 2, label: "Bildung" }])
|
||||
expect(result.current.isFollowing("topic", 1)).toBe(false)
|
||||
})
|
||||
|
||||
it("does not duplicate follows", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
})
|
||||
expect(result.current.follows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 5, "Wirtschaft")
|
||||
})
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.follows)
|
||||
expect(raw).toBeTruthy()
|
||||
// biome-ignore lint/style/noNonNullAssertion: asserted truthy above
|
||||
const parsed = JSON.parse(raw!)
|
||||
expect(parsed).toEqual([{ type: "topic", entity_id: 5, label: "Wirtschaft" }])
|
||||
})
|
||||
|
||||
it("loads from existing localStorage", () => {
|
||||
localStorage.setItem(STORAGE_KEYS.follows, JSON.stringify([{ type: "politician", entity_id: 99, label: "Test" }]))
|
||||
const { result } = renderHook(() => useFollows())
|
||||
expect(result.current.follows).toEqual([{ type: "politician", entity_id: 99, label: "Test" }])
|
||||
})
|
||||
})
|
||||
73
src/shared/hooks/use-follows.ts
Normal file
73
src/shared/hooks/use-follows.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { STORAGE_KEYS } from "@/shared/lib/constants"
|
||||
import { useCallback, useSyncExternalStore } from "react"
|
||||
|
||||
export interface Follow {
|
||||
type: "topic" | "politician"
|
||||
entity_id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
const listeners = new Set<Listener>()
|
||||
let cachedRaw: string | null = null
|
||||
let cachedFollows: Follow[] = []
|
||||
|
||||
function emitChange() {
|
||||
cachedRaw = null
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
function subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
function getSnapshot(): Follow[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.follows)
|
||||
if (raw === cachedRaw) return cachedFollows
|
||||
cachedRaw = raw
|
||||
if (!raw) {
|
||||
cachedFollows = []
|
||||
} else {
|
||||
try {
|
||||
cachedFollows = JSON.parse(raw) as Follow[]
|
||||
} catch {
|
||||
cachedFollows = []
|
||||
}
|
||||
}
|
||||
return cachedFollows
|
||||
}
|
||||
|
||||
function setFollows(follows: Follow[]) {
|
||||
localStorage.setItem(STORAGE_KEYS.follows, JSON.stringify(follows))
|
||||
emitChange()
|
||||
}
|
||||
|
||||
export function _resetFollowsCache() {
|
||||
cachedRaw = null
|
||||
cachedFollows = []
|
||||
}
|
||||
|
||||
export function useFollows() {
|
||||
const follows = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
const isFollowing = useCallback(
|
||||
(type: "topic" | "politician", entityId: number) =>
|
||||
follows.some((f) => f.type === type && f.entity_id === entityId),
|
||||
[follows],
|
||||
)
|
||||
|
||||
const follow = useCallback((type: "topic" | "politician", entityId: number, label: string) => {
|
||||
const current = getSnapshot()
|
||||
if (current.some((f) => f.type === type && f.entity_id === entityId)) return
|
||||
setFollows([...current, { type, entity_id: entityId, label }])
|
||||
}, [])
|
||||
|
||||
const unfollow = useCallback((type: "topic" | "politician", entityId: number) => {
|
||||
const current = getSnapshot()
|
||||
setFollows(current.filter((f) => !(f.type === type && f.entity_id === entityId)))
|
||||
}, [])
|
||||
|
||||
return { follows, isFollowing, follow, unfollow }
|
||||
}
|
||||
68
src/shared/hooks/use-push.ts
Normal file
68
src/shared/hooks/use-push.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { STORAGE_KEYS } from "@/shared/lib/constants"
|
||||
import { isPushSubscribed, subscribeToPush, syncFollowsToBackend, unsubscribeFromPush } from "@/shared/lib/push-client"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useDeviceId } from "./use-device-id"
|
||||
import { type Follow, useFollows } from "./use-follows"
|
||||
|
||||
type PushPermission = "default" | "granted" | "denied"
|
||||
|
||||
function getPermission(): PushPermission {
|
||||
if (typeof Notification === "undefined") return "denied"
|
||||
return Notification.permission as PushPermission
|
||||
}
|
||||
|
||||
export function usePush() {
|
||||
const deviceId = useDeviceId()
|
||||
const { follows } = useFollows()
|
||||
const [permission, setPermission] = useState<PushPermission>(getPermission)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// check subscription status on mount
|
||||
useEffect(() => {
|
||||
isPushSubscribed().then(setSubscribed)
|
||||
}, [])
|
||||
|
||||
const subscribe = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const ok = await subscribeToPush(deviceId)
|
||||
if (ok) {
|
||||
setSubscribed(true)
|
||||
setPermission(getPermission())
|
||||
localStorage.setItem(STORAGE_KEYS.pushEnabled, "true")
|
||||
// sync current follows immediately
|
||||
await syncFollowsToBackend(deviceId, follows)
|
||||
}
|
||||
return ok
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceId, follows])
|
||||
|
||||
const unsubscribe = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await unsubscribeFromPush(deviceId)
|
||||
setSubscribed(false)
|
||||
localStorage.removeItem(STORAGE_KEYS.pushEnabled)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceId])
|
||||
|
||||
// auto-sync follows to backend when they change (only if subscribed)
|
||||
useEffect(() => {
|
||||
if (!subscribed) return
|
||||
syncFollowsToBackend(deviceId, follows)
|
||||
}, [follows, subscribed, deviceId])
|
||||
|
||||
return { permission, subscribed, loading, subscribe, unsubscribe }
|
||||
}
|
||||
|
||||
// standalone sync function for use outside React hooks
|
||||
export function triggerPushSync(deviceId: string, follows: Follow[]) {
|
||||
const enabled = localStorage.getItem(STORAGE_KEYS.pushEnabled)
|
||||
if (enabled !== "true") return
|
||||
syncFollowsToBackend(deviceId, follows)
|
||||
}
|
||||
50
src/shared/hooks/use-pwa-update.ts
Normal file
50
src/shared/hooks/use-pwa-update.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { registerSW } from "virtual:pwa-register"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
let updateSW: ((reloadPage?: boolean) => Promise<void>) | null = null
|
||||
let hasUpdate = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notifyListeners() {
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
// Register the SW once at module level
|
||||
if (typeof window !== "undefined") {
|
||||
updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
hasUpdate = true
|
||||
notifyListeners()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePwaUpdate() {
|
||||
const [needRefresh, setNeedRefresh] = useState(hasUpdate)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setNeedRefresh(true)
|
||||
listeners.add(handler)
|
||||
return () => {
|
||||
listeners.delete(handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function checkForUpdate() {
|
||||
// Re-check with the server by re-registering
|
||||
const reg = await navigator.serviceWorker?.getRegistration()
|
||||
if (reg) {
|
||||
await reg.update()
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
if (updateSW) {
|
||||
await updateSW(true)
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return { needRefresh, checkForUpdate, applyUpdate }
|
||||
}
|
||||
92
src/sw.ts
Normal file
92
src/sw.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { clientsClaim } from "workbox-core"
|
||||
import { ExpirationPlugin } from "workbox-expiration"
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching"
|
||||
import { registerRoute } from "workbox-routing"
|
||||
import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies"
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
// allow the app to trigger skipWaiting via postMessage
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||
self.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
// precache all assets injected by vite-plugin-pwa
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
clientsClaim()
|
||||
|
||||
// runtime cache: AW API
|
||||
registerRoute(
|
||||
/^https:\/\/www\.abgeordnetenwatch\.de\/api\/v2\//,
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: "aw-api-cache",
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 60 * 15,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// runtime cache: Nominatim
|
||||
registerRoute(
|
||||
/^https:\/\/nominatim\.openstreetmap\.org\//,
|
||||
new CacheFirst({
|
||||
cacheName: "nominatim-cache",
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// --- Push notification handlers ---
|
||||
|
||||
interface PushPayload {
|
||||
title: string
|
||||
body: string
|
||||
url?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return
|
||||
|
||||
const payload = event.data.json() as PushPayload
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, {
|
||||
body: payload.body,
|
||||
tag: payload.tag,
|
||||
data: { url: payload.url },
|
||||
icon: "/agw/icons/icon-192.png",
|
||||
badge: "/agw/icons/icon-192.png",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close()
|
||||
|
||||
const url = (event.notification.data as { url?: string })?.url ?? "/agw/"
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => {
|
||||
// focus existing window if possible
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes("/agw/") && "focus" in client) {
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
// otherwise open new window
|
||||
return self.clients.openWindow(url)
|
||||
}),
|
||||
)
|
||||
})
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
65
vite.config.ts
Normal file
65
vite.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { dirname, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
base: "/agw/",
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
routesDirectory: "src/routes",
|
||||
generatedRouteTree: "src/routeTree.gen.ts",
|
||||
}),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
strategies: "injectManifest",
|
||||
srcDir: "src",
|
||||
filename: "sw.ts",
|
||||
registerType: "prompt",
|
||||
manifest: {
|
||||
name: "Abgeordnetenwatch",
|
||||
short_name: "Abgwatch",
|
||||
description: "Track Bundestag and Landtag votes, follow topics and politicians.",
|
||||
start_url: "/agw/",
|
||||
scope: "/agw/",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
theme_color: "#1d4ed8",
|
||||
background_color: "#f9fafb",
|
||||
lang: "en",
|
||||
icons: [
|
||||
{
|
||||
src: "/agw/icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
src: "/agw/icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
injectManifest: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
20
vitest.config.ts
Normal file
20
vitest.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { resolve } from "node:path"
|
||||
import { dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: [],
|
||||
exclude: ["node_modules/**", "server/**"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user