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:
@@ -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 & 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>•</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 • {format(new Date(synthesis.updatedAt), "HH:mm:ss")}
|
||||
En direct • {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>
|
||||
|
||||
Reference in New Issue
Block a user