Initialize web application directory and package.json
This commit is contained in:
14
apps/web/index.html
Normal file
14
apps/web/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f4f1e9" />
|
||||||
|
<meta name="description" content="Mitwirkung – Ideen einbringen, transparent beraten und gemeinsam abstimmen." />
|
||||||
|
<title>Mitwirkung | Demokratie beginnt mit dir</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
apps/web/package.json
Normal file
24
apps/web/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@mitwirkung/web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.2",
|
||||||
|
"lucide-react": "^0.511.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/web/src/App.tsx
Normal file
25
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./AuthContext";
|
||||||
|
import { AuthModal } from "./components/AuthModal";
|
||||||
|
import { Footer } from "./components/Footer";
|
||||||
|
import { Header } from "./components/Header";
|
||||||
|
import { BillDetailPage } from "./pages/BillDetailPage";
|
||||||
|
import { HomePage } from "./pages/HomePage";
|
||||||
|
import { NewBillPage } from "./pages/NewBillPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Header />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/entwurf/neu" element={<NewBillPage />} />
|
||||||
|
<Route path="/entwurf/:id" element={<BillDetailPage />} />
|
||||||
|
</Routes>
|
||||||
|
<Footer />
|
||||||
|
<AuthModal />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/web/src/AuthContext.tsx
Normal file
48
apps/web/src/AuthContext.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { api } from "./api";
|
||||||
|
import type { User } from "./types";
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
authOpen: boolean;
|
||||||
|
openAuth: () => void;
|
||||||
|
closeAuth: () => void;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ user: User | null }>("/api/auth/me")
|
||||||
|
.then((data) => setUser(data.user))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
authOpen,
|
||||||
|
openAuth: () => setAuthOpen(true),
|
||||||
|
closeAuth: () => setAuthOpen(false),
|
||||||
|
setUser,
|
||||||
|
logout: async () => {
|
||||||
|
await api.post("/api/auth/logout");
|
||||||
|
setUser(null);
|
||||||
|
},
|
||||||
|
}), [user, loading, authOpen]);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error("useAuth must be used inside AuthProvider");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
19
apps/web/src/api.ts
Normal file
19
apps/web/src/api.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: "Die Anfrage ist fehlgeschlagen." }));
|
||||||
|
throw new Error(error.message || "Die Anfrage ist fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||||
|
};
|
||||||
65
apps/web/src/components/AuthModal.tsx
Normal file
65
apps/web/src/components/AuthModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { ArrowRight, X } from "lucide-react";
|
||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { useAuth } from "../AuthContext";
|
||||||
|
import { api } from "../api";
|
||||||
|
import type { User } from "../types";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
|
export function AuthModal() {
|
||||||
|
const { authOpen, closeAuth, setUser } = useAuth();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("register");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authOpen) return;
|
||||||
|
const onKey = (event: KeyboardEvent) => event.key === "Escape" && closeAuth();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [authOpen, closeAuth]);
|
||||||
|
|
||||||
|
if (!authOpen) return null;
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(""); setBusy(true);
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
try {
|
||||||
|
const result = await api.post<{ user: User }>(`/api/auth/${mode}`, Object.fromEntries(form));
|
||||||
|
setUser(result.user);
|
||||||
|
closeAuth();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen.");
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && closeAuth()}>
|
||||||
|
<section className="auth-modal" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||||
|
<button className="modal-close" onClick={closeAuth} aria-label="Schließen"><X /></button>
|
||||||
|
<Logo />
|
||||||
|
<p className="eyebrow">Deine Stimme zählt</p>
|
||||||
|
<h2 id="auth-title">{mode === "register" ? "Werde Teil der Debatte." : "Willkommen zurück."}</h2>
|
||||||
|
<p className="modal-intro">
|
||||||
|
{mode === "register" ? "Bringe Ideen ein, prüfe Vorschläge und stimme transparent ab." : "Melde dich an, um weiter mitzugestalten."}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{mode === "register" && <label>Name<input name="name" autoComplete="name" minLength={2} required placeholder="Vor- und Nachname" /></label>}
|
||||||
|
<label>E-Mail-Adresse<input name="email" type="email" autoComplete="email" required placeholder="du@beispiel.de" /></label>
|
||||||
|
<label>Passwort<input name="password" type="password" autoComplete={mode === "login" ? "current-password" : "new-password"} minLength={8} required placeholder="Mindestens 8 Zeichen" /></label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button className="button button-primary button-wide" disabled={busy}>
|
||||||
|
{busy ? "Einen Moment …" : mode === "register" ? "Konto erstellen" : "Anmelden"}<ArrowRight size={18} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-switch">
|
||||||
|
{mode === "register" ? "Du hast bereits ein Konto?" : "Noch nicht dabei?"}
|
||||||
|
<button onClick={() => { setMode(mode === "register" ? "login" : "register"); setError(""); }}>
|
||||||
|
{mode === "register" ? "Anmelden" : "Jetzt registrieren"}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{mode === "login" && <p className="demo-hint">Demo: leonie@mitwirkung.de / demo1234</p>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/web/src/components/BillCard.tsx
Normal file
35
apps/web/src/components/BillCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { ArrowUpRight, Clock3, MessageCircle } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { Bill } from "../types";
|
||||||
|
|
||||||
|
const statusLabels = { open: "Abstimmung offen", review: "In Beratung", closed: "Abgeschlossen" };
|
||||||
|
|
||||||
|
export function BillCard({ bill, index = 0 }: { bill: Bill; index?: number }) {
|
||||||
|
const total = Number(bill.voteCount);
|
||||||
|
const support = total ? Math.round((Number(bill.yesCount) / total) * 100) : 0;
|
||||||
|
const days = Math.max(0, Math.ceil((new Date(bill.deadline).getTime() - Date.now()) / 86400000));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/entwurf/${bill.id}`} className="bill-card" style={{ "--delay": `${index * 55}ms` } as React.CSSProperties}>
|
||||||
|
<div className="card-topline">
|
||||||
|
<span className="topic-pill" style={{ "--topic": bill.topicColor } as React.CSSProperties}>{bill.topicName}</span>
|
||||||
|
<span className={`status status-${bill.status}`}><i />{statusLabels[bill.status]}</span>
|
||||||
|
</div>
|
||||||
|
<h3>{bill.title}</h3>
|
||||||
|
<p>{bill.summary}</p>
|
||||||
|
<div className="card-author">
|
||||||
|
<span className="mini-avatar">{bill.authorName.split(" ").map((p) => p[0]).join("")}</span>
|
||||||
|
<span>Eingebracht von <strong>{bill.authorName}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="support-block">
|
||||||
|
<div className="support-label"><span>Zustimmung</span><strong>{support}%</strong></div>
|
||||||
|
<div className="support-bar"><i style={{ width: `${support}%` }} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<span><MessageCircle size={15} /> {total} Stimmen</span>
|
||||||
|
<span><Clock3 size={15} /> noch {days} Tage</span>
|
||||||
|
<ArrowUpRight className="card-arrow" size={21} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/src/components/Footer.tsx
Normal file
13
apps/web/src/components/Footer.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="site-footer" id="ueber-uns">
|
||||||
|
<div className="footer-inner">
|
||||||
|
<div><Logo /><p>Eine unabhängige Plattform für konstruktive politische Beteiligung.</p></div>
|
||||||
|
<div className="footer-links"><a href="#">Transparenz</a><a href="#">Moderationsregeln</a><a href="#">Datenschutz</a><a href="#">Impressum</a></div>
|
||||||
|
<p className="footer-note">MVP-Demonstrator · Keine offizielle staatliche Plattform</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/web/src/components/Header.tsx
Normal file
48
apps/web/src/components/Header.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ChevronDown, LogOut, Menu, Plus, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
import { useAuth } from "../AuthContext";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { user, openAuth, logout } = useAuth();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="site-header">
|
||||||
|
<div className="header-inner">
|
||||||
|
<Logo />
|
||||||
|
<nav className={menuOpen ? "main-nav open" : "main-nav"} aria-label="Hauptnavigation">
|
||||||
|
<NavLink to="/" onClick={() => setMenuOpen(false)}>Entwürfe</NavLink>
|
||||||
|
<a href="/#so-funktionierts" onClick={() => setMenuOpen(false)}>So funktioniert's</a>
|
||||||
|
<a href="/#ueber-uns" onClick={() => setMenuOpen(false)}>Über das Projekt</a>
|
||||||
|
</nav>
|
||||||
|
<div className="header-actions">
|
||||||
|
<Link to="/entwurf/neu" className="button button-primary header-create">
|
||||||
|
<Plus size={17} strokeWidth={2.4} /> Entwurf einbringen
|
||||||
|
</Link>
|
||||||
|
{user ? (
|
||||||
|
<div className="profile-wrap">
|
||||||
|
<button className="profile-button" onClick={() => setProfileOpen(!profileOpen)} aria-expanded={profileOpen}>
|
||||||
|
<span className="avatar">{user.name.split(" ").map((part) => part[0]).slice(0, 2).join("")}</span>
|
||||||
|
<span>{user.name.split(" ")[0]}</span><ChevronDown size={15} />
|
||||||
|
</button>
|
||||||
|
{profileOpen && (
|
||||||
|
<div className="profile-menu">
|
||||||
|
<span>{user.email}</span>
|
||||||
|
<button onClick={() => { logout(); setProfileOpen(false); }}><LogOut size={15} /> Abmelden</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="button button-ghost login-button" onClick={openAuth}>Anmelden</button>
|
||||||
|
)}
|
||||||
|
<button className="menu-toggle" onClick={() => setMenuOpen(!menuOpen)} aria-label="Menü öffnen">
|
||||||
|
{menuOpen ? <X /> : <Menu />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/src/components/Logo.tsx
Normal file
12
apps/web/src/components/Logo.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function Logo() {
|
||||||
|
return (
|
||||||
|
<Link to="/" className="logo" aria-label="Mitwirkung Startseite">
|
||||||
|
<span className="logo-mark" aria-hidden="true">
|
||||||
|
<i /><i /><i />
|
||||||
|
</span>
|
||||||
|
<span>mitwirkung</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/src/main.tsx
Normal file
6
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||||
82
apps/web/src/pages/BillDetailPage.tsx
Normal file
82
apps/web/src/pages/BillDetailPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { ArrowLeft, Check, Clock3, Minus, Share2, ThumbsDown, ThumbsUp, UserRound } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { useAuth } from "../AuthContext";
|
||||||
|
import { api } from "../api";
|
||||||
|
import type { Bill } from "../types";
|
||||||
|
|
||||||
|
const statusLabels = { open: "Abstimmung offen", review: "In Beratung", closed: "Abgeschlossen" };
|
||||||
|
|
||||||
|
export function BillDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { user, openAuth } = useAuth();
|
||||||
|
const [bill, setBill] = useState<Bill | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [voting, setVoting] = useState(false);
|
||||||
|
|
||||||
|
const load = () => api.get<{ bill: Bill }>(`/api/bills/${id}`).then((data) => setBill(data.bill)).catch((err) => setError(err.message));
|
||||||
|
useEffect(() => { load(); }, [id, user]);
|
||||||
|
|
||||||
|
async function vote(value: "yes" | "no" | "abstain") {
|
||||||
|
if (!user) { openAuth(); return; }
|
||||||
|
setVoting(true); setError("");
|
||||||
|
try { await api.post(`/api/bills/${id}/vote`, { value }); await load(); }
|
||||||
|
catch (err) { setError(err instanceof Error ? err.message : "Abstimmung fehlgeschlagen."); }
|
||||||
|
finally { setVoting(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !bill) return <main className="detail-page"><div className="empty-state"><h2>{error}</h2><Link to="/">Zurück zur Übersicht</Link></div></main>;
|
||||||
|
if (!bill) return <main className="detail-page"><div className="loading-grid">Entwurf wird geladen …</div></main>;
|
||||||
|
|
||||||
|
const total = Number(bill.voteCount);
|
||||||
|
const yes = Number(bill.yesCount);
|
||||||
|
const no = Number(bill.noCount);
|
||||||
|
const abstain = Number(bill.abstainCount);
|
||||||
|
const percent = (count: number) => total ? Math.round(count / total * 100) : 0;
|
||||||
|
const days = Math.max(0, Math.ceil((new Date(bill.deadline).getTime() - Date.now()) / 86400000));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="detail-page">
|
||||||
|
<div className="detail-shell">
|
||||||
|
<Link to="/" className="back-link"><ArrowLeft size={17} /> Zurück zu allen Entwürfen</Link>
|
||||||
|
<div className="detail-layout">
|
||||||
|
<article className="bill-detail">
|
||||||
|
<div className="card-topline"><span className="topic-pill" style={{ "--topic": bill.topicColor } as React.CSSProperties}>{bill.topicName}</span><span className={`status status-${bill.status}`}><i />{statusLabels[bill.status]}</span></div>
|
||||||
|
<h1>{bill.title}</h1>
|
||||||
|
<p className="detail-summary">{bill.summary}</p>
|
||||||
|
<div className="detail-meta">
|
||||||
|
<span className="mini-avatar">{bill.authorName.split(" ").map((p) => p[0]).join("")}</span>
|
||||||
|
<div><small>Eingebracht von</small><strong>{bill.authorName}</strong></div>
|
||||||
|
<div className="meta-date"><small>Veröffentlicht</small><strong>{new Date(bill.createdAt).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" })}</strong></div>
|
||||||
|
<button title="Link teilen" onClick={() => navigator.clipboard?.writeText(location.href)}><Share2 size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<section className="detail-copy"><span>01</span><div><h2>Ausgangslage</h2><p>{bill.problem}</p></div></section>
|
||||||
|
<section className="detail-copy"><span>02</span><div><h2>Vorgeschlagene Lösung</h2><p>{bill.solution}</p></div></section>
|
||||||
|
<section className="detail-copy"><span>03</span><div><h2>Erwartete Wirkung</h2><p>{bill.impact}</p></div></section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside className="vote-panel">
|
||||||
|
<div className="vote-panel-head"><p className="eyebrow">Deine Entscheidung</p><h2>Wie stimmst du ab?</h2><p>Du kannst deine Stimme bis zum Ende der Abstimmung ändern.</p></div>
|
||||||
|
<div className="deadline"><Clock3 size={18} /><div><small>Abstimmung endet</small><strong>in {days} Tagen</strong></div></div>
|
||||||
|
<div className="vote-buttons">
|
||||||
|
<button className={bill.userVote === "yes" ? "vote-yes selected" : "vote-yes"} disabled={voting} onClick={() => vote("yes")}><ThumbsUp /><span><strong>Dafür</strong><small>Ich unterstütze den Entwurf</small></span>{bill.userVote === "yes" && <Check />}</button>
|
||||||
|
<button className={bill.userVote === "no" ? "vote-no selected" : "vote-no"} disabled={voting} onClick={() => vote("no")}><ThumbsDown /><span><strong>Dagegen</strong><small>Ich lehne den Entwurf ab</small></span>{bill.userVote === "no" && <Check />}</button>
|
||||||
|
<button className={bill.userVote === "abstain" ? "vote-neutral selected" : "vote-neutral"} disabled={voting} onClick={() => vote("abstain")}><Minus /><span><strong>Enthaltung</strong><small>Ich beziehe keine Position</small></span>{bill.userVote === "abstain" && <Check />}</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<div className="result-block">
|
||||||
|
<div className="result-title"><strong>Aktueller Stand</strong><span><UserRound size={14} /> {total} Stimmen</span></div>
|
||||||
|
<div className="result-row"><span>Dafür</span><div><i className="yes" style={{ width: `${percent(yes)}%` }} /></div><strong>{percent(yes)}%</strong></div>
|
||||||
|
<div className="result-row"><span>Dagegen</span><div><i className="no" style={{ width: `${percent(no)}%` }} /></div><strong>{percent(no)}%</strong></div>
|
||||||
|
<div className="result-row"><span>Enthaltung</span><div><i className="neutral" style={{ width: `${percent(abstain)}%` }} /></div><strong>{percent(abstain)}%</strong></div>
|
||||||
|
</div>
|
||||||
|
<p className="vote-note"><ShieldCheckIcon /> Stimmen werden pro Nutzerkonto einmalig gezählt.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShieldCheckIcon() { return <Check size={14} />; }
|
||||||
95
apps/web/src/pages/HomePage.tsx
Normal file
95
apps/web/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { ArrowDown, ArrowRight, FilePenLine, SearchCheck, ShieldCheck, Vote } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { BillCard } from "../components/BillCard";
|
||||||
|
import type { Bill, Topic } from "../types";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const [bills, setBills] = useState<Bill[]>([]);
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [stats, setStats] = useState({ users: 0, activeBills: 0, votes: 0 });
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [sort, setSort] = useState("new");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get<{ topics: Topic[] }>("/api/topics"),
|
||||||
|
api.get<{ stats: typeof stats }>("/api/stats"),
|
||||||
|
]).then(([topicData, statsData]) => { setTopics(topicData.topics); setStats(statsData.stats); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (topic) params.set("topic", topic);
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
|
api.get<{ bills: Bill[] }>(`/api/bills?${params}`).then((data) => setBills(data.bills)).finally(() => setLoading(false));
|
||||||
|
}, [topic, status, sort]);
|
||||||
|
|
||||||
|
const number = useMemo(() => new Intl.NumberFormat("de-DE"), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main>
|
||||||
|
<section className="hero">
|
||||||
|
<div className="hero-orbit orbit-one" /><div className="hero-orbit orbit-two" />
|
||||||
|
<div className="hero-content">
|
||||||
|
<p className="eyebrow"><span /> Politik ist, was wir daraus machen</p>
|
||||||
|
<h1>Demokratie beginnt<br />mit <em>deiner Idee.</em></h1>
|
||||||
|
<p className="hero-lead">Bringe Gesetzesvorschläge ein, diskutiere Lösungen und stimme über die Ideen ab, die unser Zusammenleben verändern können.</p>
|
||||||
|
<div className="hero-actions">
|
||||||
|
<Link to="/entwurf/neu" className="button button-primary button-large">Idee einbringen <ArrowRight size={19} /></Link>
|
||||||
|
<a href="#entwuerfe" className="text-link">Entwürfe entdecken <ArrowDown size={18} /></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="hero-manifesto">
|
||||||
|
<span className="manifesto-number">01</span>
|
||||||
|
<p>Gute Politik entsteht dort, wo <strong>unterschiedliche Perspektiven</strong> sichtbar werden.</p>
|
||||||
|
<div className="manifesto-line" />
|
||||||
|
<span>Offen · Transparent · Gemeinsam</span>
|
||||||
|
</aside>
|
||||||
|
<div className="hero-stats">
|
||||||
|
<div><strong>{number.format(stats.users)}</strong><span>aktive Mitglieder</span></div>
|
||||||
|
<div><strong>{number.format(stats.activeBills)}</strong><span>offene Entwürfe</span></div>
|
||||||
|
<div><strong>{number.format(stats.votes)}</strong><span>abgegebene Stimmen</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bills-section" id="entwuerfe">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div><p className="eyebrow">Aktuelle Beteiligung</p><h2>Worüber wir gerade<br /><em>entscheiden.</em></h2></div>
|
||||||
|
<p>Jeder Entwurf ist öffentlich einsehbar. Lies die Argumente, bilde dir eine Meinung und gib deine Stimme ab.</p>
|
||||||
|
</div>
|
||||||
|
<div className="topic-tabs" role="tablist" aria-label="Themen filtern">
|
||||||
|
<button className={!topic ? "active" : ""} onClick={() => setTopic("")}>Alle Themen</button>
|
||||||
|
{topics.map((item) => <button key={item.id} className={topic === item.slug ? "active" : ""} onClick={() => setTopic(item.slug)}>{item.name}<sup>{item.billCount}</sup></button>)}
|
||||||
|
</div>
|
||||||
|
<div className="filter-row">
|
||||||
|
<span>{bills.length} Entwürfe</span>
|
||||||
|
<div>
|
||||||
|
<label>Status<select value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option><option value="open">Abstimmung offen</option><option value="review">In Beratung</option><option value="closed">Abgeschlossen</option></select></label>
|
||||||
|
<label>Sortierung<select value={sort} onChange={(e) => setSort(e.target.value)}><option value="new">Neueste zuerst</option><option value="votes">Meiste Stimmen</option></select></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? <div className="loading-grid">Entwürfe werden geladen …</div> : bills.length ? (
|
||||||
|
<div className="bill-grid">{bills.map((bill, index) => <BillCard bill={bill} index={index} key={bill.id} />)}</div>
|
||||||
|
) : <div className="empty-state"><h3>Keine Entwürfe gefunden</h3><p>Ändere die Filter oder bringe den ersten passenden Vorschlag ein.</p></div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="process-section" id="so-funktionierts">
|
||||||
|
<div className="process-heading"><p className="eyebrow">Ein klarer Prozess</p><h2>Von der Idee zur<br /><em>gemeinsamen Position.</em></h2></div>
|
||||||
|
<div className="process-grid">
|
||||||
|
<article><span>01</span><FilePenLine /><h3>Entwurf einbringen</h3><p>Beschreibe Problem, Lösung und erwartete Wirkung so konkret wie möglich.</p></article>
|
||||||
|
<article><span>02</span><SearchCheck /><h3>Öffentlich prüfen</h3><p>Die Community liest den Vorschlag und kann alle Entscheidungsgrundlagen nachvollziehen.</p></article>
|
||||||
|
<article><span>03</span><Vote /><h3>Position beziehen</h3><p>Stimme dafür, dagegen oder enthalte dich. Deine Position kann bis zum Ende geändert werden.</p></article>
|
||||||
|
<article><span>04</span><ShieldCheck /><h3>Ergebnis sichern</h3><p>Resultate bleiben transparent dokumentiert und bilden eine belastbare öffentliche Position.</p></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/pages/NewBillPage.tsx
Normal file
56
apps/web/src/pages/NewBillPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { ArrowLeft, ArrowRight, CheckCircle2, FileText, Lightbulb, Target } from "lucide-react";
|
||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../AuthContext";
|
||||||
|
import { api } from "../api";
|
||||||
|
import type { Topic } from "../types";
|
||||||
|
|
||||||
|
export function NewBillPage() {
|
||||||
|
const { user, loading, openAuth } = useAuth();
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => { api.get<{ topics: Topic[] }>("/api/topics").then((d) => setTopics(d.topics)); }, []);
|
||||||
|
useEffect(() => { if (!loading && !user) openAuth(); }, [loading, user, openAuth]);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault(); setBusy(true); setError("");
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const data = Object.fromEntries(form);
|
||||||
|
try {
|
||||||
|
const result = await api.post<{ id: number }>("/api/bills", { ...data, deadline: new Date(String(data.deadline)).toISOString() });
|
||||||
|
navigate(`/entwurf/${result.id}`);
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : "Entwurf konnte nicht gespeichert werden."); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDate = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="create-page">
|
||||||
|
<div className="create-shell">
|
||||||
|
<Link to="/" className="back-link"><ArrowLeft size={17} /> Zurück zur Übersicht</Link>
|
||||||
|
<div className="create-heading"><p className="eyebrow">Dein Vorschlag</p><h1>Eine gute Idee braucht<br /><em>eine klare Form.</em></h1><p>Beschreibe deinen Entwurf so konkret, verständlich und überprüfbar wie möglich.</p></div>
|
||||||
|
<div className="create-layout">
|
||||||
|
<form className="bill-form" onSubmit={submit}>
|
||||||
|
<section><div className="form-section-title"><span>01</span><div><h2>Grundidee</h2><p>Worum geht es in deinem Vorschlag?</p></div></div>
|
||||||
|
<label>Titel des Entwurfs <small>10–140 Zeichen</small><input name="title" minLength={10} maxLength={140} required placeholder="z. B. Kostenfreie Bibliotheksausweise für alle Jugendlichen" /></label>
|
||||||
|
<label>Kurzbeschreibung <small>30–300 Zeichen</small><textarea name="summary" minLength={30} maxLength={300} rows={3} required placeholder="Fasse die zentrale Idee in zwei bis drei Sätzen zusammen." /></label>
|
||||||
|
<div className="form-row"><label>Themenbereich<select name="topicId" required defaultValue=""><option value="" disabled>Bitte wählen</option>{topics.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></label><label>Abstimmung bis<input name="deadline" type="date" min={minDate} required /></label></div>
|
||||||
|
</section>
|
||||||
|
<section><div className="form-section-title"><span>02</span><div><h2>Begründung</h2><p>Was soll sich verändern und warum?</p></div></div>
|
||||||
|
<label>Ausgangslage und Problem <small>mind. 40 Zeichen</small><textarea name="problem" minLength={40} maxLength={2000} rows={5} required placeholder="Beschreibe nachvollziehbar, welches Problem heute besteht und wen es betrifft." /></label>
|
||||||
|
<label>Vorgeschlagene Lösung <small>mind. 40 Zeichen</small><textarea name="solution" minLength={40} maxLength={3000} rows={6} required placeholder="Welche konkrete gesetzliche oder politische Änderung schlägst du vor?" /></label>
|
||||||
|
<label>Erwartete Wirkung <small>mind. 30 Zeichen</small><textarea name="impact" minLength={30} maxLength={2000} rows={4} required placeholder="Welche positiven Effekte, Kosten oder möglichen Zielkonflikte sind zu erwarten?" /></label>
|
||||||
|
</section>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<div className="form-submit"><p>Mit dem Einreichen stimmst du unseren Moderationsregeln zu.</p><button className="button button-primary button-large" disabled={busy || !user}>{busy ? "Wird eingereicht …" : "Entwurf veröffentlichen"}<ArrowRight size={18} /></button></div>
|
||||||
|
</form>
|
||||||
|
<aside className="writing-guide"><p className="eyebrow">Schreibhilfe</p><h3>Was einen guten Entwurf ausmacht</h3><div><Lightbulb /><p><strong>Verständlich</strong>Vermeide Fachsprache oder erkläre notwendige Begriffe.</p></div><div><Target /><p><strong>Konkret</strong>Formuliere ein klares Ziel und einen umsetzbaren Weg dorthin.</p></div><div><FileText /><p><strong>Nachvollziehbar</strong>Trenne Ausgangslage, Lösung und erwartete Wirkung.</p></div><div><CheckCircle2 /><p><strong>Fair</strong>Benenne auch mögliche Kosten, Nachteile oder Zielkonflikte.</p></div></aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/src/types.ts
Normal file
33
apps/web/src/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export type User = { id: number; name: string; email: string };
|
||||||
|
|
||||||
|
export type Topic = {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
billCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bill = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
problem: string;
|
||||||
|
solution: string;
|
||||||
|
impact: string;
|
||||||
|
status: "open" | "review" | "closed";
|
||||||
|
createdAt: string;
|
||||||
|
deadline: string;
|
||||||
|
authorId: number;
|
||||||
|
authorName: string;
|
||||||
|
topicId: number;
|
||||||
|
topicName: string;
|
||||||
|
topicSlug: string;
|
||||||
|
topicColor: string;
|
||||||
|
yesCount: number;
|
||||||
|
noCount: number;
|
||||||
|
abstainCount: number;
|
||||||
|
voteCount: number;
|
||||||
|
userVote?: "yes" | "no" | "abstain" | null;
|
||||||
|
};
|
||||||
20
apps/web/tsconfig.app.json
Normal file
20
apps/web/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
apps/web/tsconfig.json
Normal file
7
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
10
apps/web/tsconfig.node.json
Normal file
10
apps/web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
apps/web/vite.config.ts
Normal file
10
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: { "/api": "http://localhost:4000" },
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user