Initialize web application directory and package.json

This commit is contained in:
2026-06-12 10:04:19 +02:00
parent 51ec5e08a7
commit 06088559d8
19 changed files with 622 additions and 0 deletions

14
apps/web/index.html Normal file
View 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
View 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
View 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>
);
}

View 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
View 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) }),
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>);

View 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} />; }

View 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>
</>
);
}

View 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
View 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;
};

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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" },
},
});