Add a democratic idea submission and AI synthesis platform
Implement a full-stack application with a React frontend and a Python Flask backend. The backend integrates with an AI agent to filter political ideas for democratic values and synthesize accepted ideas into a collective voice. Includes API endpoints for idea submission, retrieval, and synthesis, along with database persistence. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 31c5f770-9905-46af-a938-9d40ef3d4404 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/Xzzm5QH Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft, Shield, Brain, BookOpen } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] bg-background flex justify-center py-12 px-4 md:px-8">
|
||||
<div className="max-w-3xl w-full">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="mb-8 font-mono tracking-widest uppercase text-xs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Return to the Agora
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<header className="mb-12 space-y-4">
|
||||
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">About La Voix du Peuple</h1>
|
||||
<p className="text-xl font-serif text-muted-foreground leading-relaxed">
|
||||
A digital town square where individual voices forge a collective democratic manifesto, protected by artificial intelligence.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-12">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-xl font-bold font-serif border-b border-border/50 pb-2">
|
||||
<BookOpen className="h-6 w-6 text-primary" />
|
||||
<h2>The Concept</h2>
|
||||
</div>
|
||||
<p className="leading-relaxed text-foreground/90 font-serif text-lg">
|
||||
Democracy requires both free expression and a shared foundation of values. "La Voix du Peuple" (The Voice of the People) provides a platform where citizens can submit their ideas for the betterment of society. These distinct, fragile voices are woven together in real-time to create a living, breathing synthesis—a modern digital pamphlet reflecting our collective aspirations.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-xl font-bold font-serif border-b border-border/50 pb-2">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
<h2>The Democratic Filter</h2>
|
||||
</div>
|
||||
<p className="leading-relaxed text-foreground/90 font-serif text-lg mb-4">
|
||||
Not all speech serves the public good. To protect the integrity of the agora, an AI agent acts as a civic guardian. Every submitted idea is evaluated against core democratic principles before it can enter the collective synthesis.
|
||||
</p>
|
||||
<div className="bg-muted/30 p-6 rounded-none border-l-4 border-primary">
|
||||
<h3 className="font-bold font-mono uppercase tracking-widest text-sm mb-3">Protected Values</h3>
|
||||
<ul className="space-y-2 font-serif list-disc pl-5">
|
||||
<li>Respect for human rights and dignity</li>
|
||||
<li>Equality and anti-discrimination</li>
|
||||
<li>Constructive civic discourse (no hate speech or violence)</li>
|
||||
<li>Commitment to democratic processes</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="leading-relaxed text-foreground/90 font-serif text-lg mt-4">
|
||||
If an idea violates these principles, it is rejected with a clear explanation. This transparency ensures the filter remains accountable to the people it serves.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-xl font-bold font-serif border-b border-border/50 pb-2">
|
||||
<Brain className="h-6 w-6 text-primary" />
|
||||
<h2>The Synthesis</h2>
|
||||
</div>
|
||||
<p className="leading-relaxed text-foreground/90 font-serif text-lg">
|
||||
Once accepted, an idea doesn't just sit in a list. A second AI agent constantly reads the stream of accepted ideas and weaves them into a single, cohesive narrative. This text—updated every 15 seconds—represents the prevailing mood, concerns, and hopes of the populace. It is the voice of the people, distilled.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useSubmitIdea,
|
||||
useListIdeas,
|
||||
useGetIdeaStats,
|
||||
useGetSynthesis,
|
||||
getListIdeasQueryKey,
|
||||
getGetIdeaStatsQueryKey
|
||||
} from "@workspace/api-client-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Loader2, PenTool, CheckCircle2, XCircle, AlertCircle, TrendingUp, Users } from "lucide-react";
|
||||
|
||||
const submitIdeaSchema = z.object({
|
||||
content: z.string()
|
||||
.min(10, "Your idea must be at least 10 characters long to carry weight.")
|
||||
.max(1000, "Brevity is the soul of wit. Keep it under 1000 characters."),
|
||||
author: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
type SubmitIdeaValues = z.infer<typeof submitIdeaSchema>;
|
||||
|
||||
export default function Home() {
|
||||
const queryClient = useQueryClient();
|
||||
const [submitResult, setSubmitResult] = React.useState<{ success: boolean; message: string; reason?: string } | null>(null);
|
||||
|
||||
const submitIdea = useSubmitIdea();
|
||||
const { data: ideas, isLoading: isLoadingIdeas } = useListIdeas();
|
||||
const { data: stats } = useGetIdeaStats();
|
||||
const { data: synthesis, isLoading: isLoadingSynthesis } = useGetSynthesis({
|
||||
query: {
|
||||
refetchInterval: 15000,
|
||||
}
|
||||
});
|
||||
|
||||
const form = useForm<SubmitIdeaValues>({
|
||||
resolver: zodResolver(submitIdeaSchema),
|
||||
defaultValues: {
|
||||
content: "",
|
||||
author: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: SubmitIdeaValues) => {
|
||||
setSubmitResult(null);
|
||||
submitIdea.mutate({ data }, {
|
||||
onSuccess: (result) => {
|
||||
if (result.accepted) {
|
||||
setSubmitResult({
|
||||
success: true,
|
||||
message: "Your voice has been added to the collective manifesto."
|
||||
});
|
||||
form.reset();
|
||||
} else {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
message: "Your submission was rejected by the democratic filter.",
|
||||
reason: result.reason
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
|
||||
},
|
||||
onError: (error) => {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
message: "An error occurred while submitting your idea. Please try again."
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 grid md:grid-cols-2 lg:grid-cols-[1fr_1.2fr] h-[calc(100vh-4rem)]">
|
||||
{/* Left Column: Input & Feed */}
|
||||
<div className="flex flex-col border-r border-border/40 bg-card">
|
||||
<div className="p-6 md:p-8 flex flex-col gap-6 flex-shrink-0 border-b border-border/40">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-serif font-bold text-primary tracking-tight">Speak to the Republic</h1>
|
||||
<p className="text-muted-foreground font-mono text-sm uppercase tracking-wider">
|
||||
Your voice matters. Submit your vision for the future.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">Your Idea</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What change do we need? Speak plainly."
|
||||
className="min-h-[120px] resize-none font-serif text-lg bg-background border-primary/20 focus-visible:ring-primary placeholder:text-muted-foreground/50"
|
||||
data-testid="input-idea-content"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="author"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="sr-only">Pseudonym (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Pseudonym (optional)"
|
||||
className="bg-background border-primary/20 focus-visible:ring-primary font-mono text-sm"
|
||||
data-testid="input-idea-author"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="font-bold tracking-wide flex-shrink-0"
|
||||
disabled={submitIdea.isPending}
|
||||
data-testid="button-submit-idea"
|
||||
>
|
||||
{submitIdea.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Publishing...</>
|
||||
) : (
|
||||
<><PenTool className="mr-2 h-4 w-4" /> Proclaim</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{submitResult && (
|
||||
<Alert variant={submitResult.success ? "default" : "destructive"} className="mt-2" data-testid={`alert-submit-${submitResult.success ? 'success' : 'error'}`}>
|
||||
{submitResult.success ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
<AlertTitle>{submitResult.success ? "Proclamation Accepted" : "Proclamation Rejected"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{submitResult.message}
|
||||
{submitResult.reason && (
|
||||
<div className="mt-2 text-sm italic border-l-2 pl-2 py-1 border-current opacity-90">
|
||||
Reason: {submitResult.reason}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feed of recent accepted ideas */}
|
||||
<ScrollArea className="flex-1 bg-muted/30">
|
||||
<div className="p-6 md:p-8">
|
||||
<h2 className="text-xs font-mono font-bold uppercase tracking-widest text-muted-foreground mb-6 flex items-center gap-2">
|
||||
<TrendingUp className="h-3 w-3" /> Recent Proclamations
|
||||
</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
{isLoadingIdeas ? (
|
||||
<div className="flex justify-center p-8 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : ideas && ideas.length > 0 ? (
|
||||
ideas.slice(0, 20).map((idea) => (
|
||||
<div key={idea.id} className="group relative pl-4 border-l border-border/60 hover:border-primary/50 transition-colors" data-testid={`card-idea-${idea.id}`}>
|
||||
<p className="text-foreground/90 font-serif leading-relaxed">
|
||||
{idea.content}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs font-mono text-muted-foreground">
|
||||
<span className="font-semibold text-primary/70">{idea.author || "Anonymous Citizen"}</span>
|
||||
<span>•</span>
|
||||
<span>{format(new Date(idea.createdAt), "MMM d, h:mm a")}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center p-8 text-muted-foreground font-mono text-sm border border-dashed border-border/60">
|
||||
No proclamations recorded yet. Be the first to speak.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Synthesis */}
|
||||
<div className="flex flex-col bg-[#F9F7F1] relative overflow-hidden">
|
||||
{/* Decorative noise/texture overlay could go here */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-multiply"
|
||||
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=%220 0 200 200%22 xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cfilter id=%22noiseFilter%22%3E%3CfeTurbulence type=%22fractalNoise%22 baseFrequency=%220.65%22 numOctaves=%223%22 stitchTiles=%22stitch%22/%3E%3C/filter%3E%3Crect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23noiseFilter)%22/%3E%3C/svg%3E")' }}></div>
|
||||
|
||||
<div className="p-6 md:p-12 flex-1 flex flex-col relative z-10 overflow-y-auto">
|
||||
<div className="flex justify-between items-start mb-12">
|
||||
<div>
|
||||
<h2 className="text-sm font-mono font-bold uppercase tracking-widest text-primary flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> La Voix du Peuple
|
||||
</h2>
|
||||
<p className="text-xs font-mono text-muted-foreground mt-1">
|
||||
The living synthesis of democratic thought
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="flex gap-4 text-xs font-mono text-right" data-testid="text-stats">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground">Voices Joined</span>
|
||||
<span className="font-bold text-primary">{stats.accepted}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground">Voices Rejected</span>
|
||||
<span className="font-bold text-destructive">{stats.rejected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center max-w-2xl mx-auto w-full">
|
||||
{isLoadingSynthesis ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="font-mono text-sm uppercase tracking-widest">Listening to the people...</span>
|
||||
</div>
|
||||
) : synthesis ? (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out space-y-8">
|
||||
<div
|
||||
className="prose prose-lg prose-slate max-w-none text-2xl md:text-3xl lg:text-4xl leading-tight font-serif text-foreground"
|
||||
data-testid="text-synthesis-content"
|
||||
>
|
||||
{synthesis.text ? (
|
||||
<div dangerouslySetExpandedAutocorrect={undefined}>{synthesis.text}</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic text-center text-xl">The pages of our manifesto remain empty. Speak, and it shall be written.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{synthesis.updatedAt && (
|
||||
<div className="pt-8 border-t border-border/40 mt-12 flex justify-between items-center text-xs font-mono text-muted-foreground" data-testid="text-synthesis-meta">
|
||||
<span>Synthesized from {synthesis.ideaCount} democratic ideas</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
Live • {format(new Date(synthesis.updatedAt), "HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<span className="font-mono text-sm uppercase tracking-widest">Failed to retrieve the manifesto</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user