Update website to display all content in French and add a values disclaimer

Translate UI elements to French, replace rejected voice notifications with a disclaimer, and add sourced legal values.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: fcd1a349-b688-425c-9bb4-acdbfc2e808e
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/zrclMhx
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
pironantoine
2026-04-04 06:08:33 +00:00
parent 432509b2d3
commit 6730700f5a
4 changed files with 335 additions and 108 deletions
+179 -84
View File
@@ -3,52 +3,90 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useQueryClient } from "@tanstack/react-query";
import {
useSubmitIdea,
useListIdeas,
useGetIdeaStats,
import {
useSubmitIdea,
useListIdeas,
useGetIdeaStats,
useGetSynthesis,
getListIdeasQueryKey,
getGetIdeaStatsQueryKey
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";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
} 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."),
.min(10, "Votre idée doit comporter au moins 10 caractères pour avoir du poids.")
.max(1000, "La concision est une vertu. Restez sous 1 000 caractères."),
author: z.string().max(100).optional(),
});
type SubmitIdeaValues = z.infer<typeof submitIdeaSchema>;
const VALEURS = [
{
source: "DUDH — Art. 1 (ONU, 1948)",
texte: "Tous les êtres humains naissent libres et égaux en dignité et en droits.",
},
{
source: "DUDH — Art. 19",
texte: "Tout individu a droit à la liberté d'opinion et d'expression.",
},
{
source: "DUDH — Art. 20",
texte: "Tout appel à la haine nationale, raciale ou religieuse constituant une incitation à la discrimination, à l'hostilité ou à la violence est interdit par la loi.",
},
{
source: "PIDCP — Art. 20 (ONU, 1966)",
texte: "Tout appel à la haine nationale, raciale ou religieuse qui constitue une incitation à la discrimination, à l'hostilité ou à la violence est interdit par la loi.",
},
{
source: "CEDH — Art. 10 (Conseil de l'Europe, 1950)",
texte: "Toute personne a droit à la liberté d'expression, sous réserve des restrictions nécessaires à la protection des droits d'autrui.",
},
{
source: "CEDH — Art. 17",
texte: "Aucune disposition de la Convention ne peut être interprétée comme impliquant le droit de se livrer à une activité visant à la destruction des droits reconnus.",
},
{
source: "Charte des droits fondamentaux de l'UE — Art. 1 (2000)",
texte: "La dignité humaine est inviolable. Elle doit être respectée et protégée.",
},
];
export default function Home() {
const queryClient = useQueryClient();
const [submitResult, setSubmitResult] = React.useState<{ success: boolean; message: string; reason?: string } | null>(null);
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,
}
query: { refetchInterval: 15000 },
});
const form = useForm<SubmitIdeaValues>({
resolver: zodResolver(submitIdeaSchema),
defaultValues: {
content: "",
author: "",
},
defaultValues: { content: "", author: "" },
});
const onSubmit = (data: SubmitIdeaValues) => {
@@ -56,41 +94,41 @@ export default function Home() {
submitIdea.mutate({ data }, {
onSuccess: (result) => {
if (result.accepted) {
setSubmitResult({
success: true,
message: "Your voice has been added to the collective manifesto."
setSubmitResult({
success: true,
message: "Votre voix a été intégrée au manifeste collectif.",
});
form.reset();
} else {
setSubmitResult({
success: false,
message: "Your submission was rejected by the democratic filter.",
reason: result.reason
setSubmitResult({
success: false,
message: "Cette contribution n'a pas pu être intégrée au manifeste car elle n'est pas en accord avec les valeurs fondamentales qui guident cet espace commun.",
reason: result.reason ?? undefined,
});
}
// 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."
onError: () => {
setSubmitResult({
success: false,
message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer.",
});
}
},
});
};
return (
<div className="flex-1 grid md:grid-cols-2 lg:grid-cols-[1fr_1.2fr] h-[calc(100vh-4rem)]">
{/* Left Column: Input & Feed */}
{/* Colonne gauche : formulaire + fil des idées */}
<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>
<h1 className="text-3xl font-serif font-bold text-primary tracking-tight">
Parlez à la République
</h1>
<p className="text-muted-foreground font-mono text-sm uppercase tracking-wider">
Your voice matters. Submit your vision for the future.
Votre voix compte. Partagez votre vision pour l'avenir.
</p>
</div>
@@ -101,49 +139,49 @@ export default function Home() {
name="content"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">Your Idea</FormLabel>
<FormLabel className="sr-only">Votre idée</FormLabel>
<FormControl>
<Textarea
placeholder="What change do we need? Speak plainly."
<Textarea
placeholder="Quel changement souhaitez-vous ? Exprimez-vous librement."
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}
{...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>
<FormLabel className="sr-only">Pseudonyme (optionnel)</FormLabel>
<FormControl>
<Input
placeholder="Pseudonym (optional)"
<Input
placeholder="Pseudonyme (optionnel)"
className="bg-background border-primary/20 focus-visible:ring-primary font-mono text-sm"
data-testid="input-idea-author"
{...field}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
<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...</>
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Envoi…</>
) : (
<><PenTool className="mr-2 h-4 w-4" /> Proclaim</>
<><PenTool className="mr-2 h-4 w-4" /> Proclamer</>
)}
</Button>
</div>
@@ -151,28 +189,67 @@ export default function Home() {
</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>
<Alert
variant={submitResult.success ? "default" : "default"}
className={`mt-2 ${submitResult.success ? "" : "border-amber-300 bg-amber-50 text-amber-900"}`}
data-testid={`alert-submit-${submitResult.success ? "success" : "info"}`}
>
{submitResult.success
? <CheckCircle2 className="h-4 w-4 text-green-600" />
: <Info className="h-4 w-4 text-amber-600" />
}
<AlertTitle>
{submitResult.success ? "Proclamation enregistrée" : "Contribution non retenue"}
</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}
{submitResult.reason && !submitResult.success && (
<div className="mt-2 text-sm italic border-l-2 pl-2 py-1 border-amber-400 opacity-90">
{submitResult.reason}
</div>
)}
</AlertDescription>
</Alert>
)}
{/* Encart valeurs */}
<Accordion type="single" collapsible className="border border-border/40 rounded-sm px-3">
<AccordionItem value="valeurs" className="border-none">
<AccordionTrigger className="text-xs font-mono uppercase tracking-widest text-muted-foreground hover:no-underline py-3">
<span className="flex items-center gap-2">
<Scale className="h-3 w-3" /> Nos valeurs &amp; fondements juridiques
</span>
</AccordionTrigger>
<AccordionContent>
<p className="text-xs text-muted-foreground font-serif mb-4 leading-relaxed">
Cet espace est régi par les grands textes fondateurs du droit international
des droits humains. Toute contribution est évaluée à leur lumière.
</p>
<div className="space-y-3">
{VALEURS.map((v) => (
<div key={v.source} className="border-l-2 border-primary/30 pl-3">
<p className="text-xs font-mono font-semibold text-primary/80 mb-0.5">{v.source}</p>
<p className="text-xs font-serif text-foreground/80 leading-relaxed italic">« {v.texte} »</p>
</div>
))}
</div>
<p className="text-xs text-muted-foreground font-mono mt-4">
Sources : DUDH (ONU 1948) · PIDCP (ONU 1966) · CEDH (Conseil de l'Europe 1950) ·
Charte des droits fondamentaux de l'UE (2000) · Statut de Rome / CPI (1998) ·
CERD (ONU 1965)
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* Feed of recent accepted ideas */}
{/* Fil des proclamations acceptées */}
<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
<TrendingUp className="h-3 w-3" /> Proclamations récentes
</h2>
<div className="space-y-8">
{isLoadingIdeas ? (
<div className="flex justify-center p-8 text-muted-foreground">
@@ -180,20 +257,28 @@ export default function Home() {
</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}`}>
<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 className="font-semibold text-primary/70">
{idea.author || "Citoyen anonyme"}
</span>
<span>&bull;</span>
<span>{format(new Date(idea.createdAt), "MMM d, h:mm a")}</span>
<span>
{format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })}
</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.
Aucune proclamation enregistrée. Soyez le premier à prendre la parole.
</div>
)}
</div>
@@ -201,12 +286,15 @@ export default function Home() {
</ScrollArea>
</div>
{/* Right Column: Synthesis */}
{/* Colonne droite : synthèse vivante */}
<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="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-multiply"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
}}
/>
<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>
@@ -214,20 +302,16 @@ export default function Home() {
<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
La synthèse vivante de la pensée démocratique
</p>
</div>
{stats && (
<div className="flex gap-4 text-xs font-mono text-right" data-testid="text-stats">
<div className="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="text-muted-foreground">Voix rejointes</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>
@@ -236,30 +320,39 @@ export default function Home() {
{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>
<span className="font-mono text-sm uppercase tracking-widest">
À l'écoute du peuple
</span>
</div>
) : synthesis ? (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out space-y-8">
<div
<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>
<div>{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>
<p className="text-muted-foreground italic text-center text-xl">
Les pages de notre manifeste sont encore vierges. Prenez la parole.
</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>
<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>
Synthèse de {synthesis.ideaCount} idée{synthesis.ideaCount !== 1 ? "s" : ""} citoyenne{synthesis.ideaCount !== 1 ? "s" : ""}
</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 className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
Live &bull; {format(new Date(synthesis.updatedAt), "HH:mm:ss")}
En direct &bull; {format(new Date(synthesis.updatedAt), "HH:mm:ss")}
</span>
</div>
)}
@@ -267,7 +360,9 @@ export default function Home() {
) : (
<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>
<span className="font-mono text-sm uppercase tracking-widest">
Impossible de récupérer le manifeste
</span>
</div>
)}
</div>