diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 1c15a36..1aa59ea 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -16,6 +16,7 @@ "cors": "^2", "drizzle-orm": "catalog:", "express": "^5", + "openai": "^6.33.0", "pino": "^9", "pino-http": "^10" }, diff --git a/artifacts/api-server/src/lib/ai-agent.ts b/artifacts/api-server/src/lib/ai-agent.ts new file mode 100644 index 0000000..980fcf1 --- /dev/null +++ b/artifacts/api-server/src/lib/ai-agent.ts @@ -0,0 +1,93 @@ +import OpenAI from "openai"; +import { logger } from "./logger"; + +const openai = new OpenAI({ + baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL, + apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY, +}); + +export interface FilterResult { + accepted: boolean; + reason?: string; +} + +export async function filterIdea(content: string): Promise { + const systemPrompt = `Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne. +Ta mission : analyser les idées politiques soumises et décider si elles respectent les valeurs démocratiques. + +Critères d'ACCEPTATION : +- L'idée promeut les droits fondamentaux, la liberté, l'égalité, la justice sociale +- L'idée propose des améliorations concrètes pour la société +- L'idée est constructive, même si critique du gouvernement ou des institutions +- L'idée débat de politiques publiques de manière civile + +Critères de REJET : +- Contenu fasciste, totalitaire ou autoritaire +- Appels à la haine, discrimination ou violence +- Négation de droits fondamentaux pour des groupes de personnes +- Propagande pour des idéologies qui détruisent la démocratie +- Contenu raciste, sexiste, homophobe ou xénophobe +- Appels au renversement violent de la démocratie + +Réponds UNIQUEMENT avec un JSON valide, sans markdown, dans ce format exact : +{"accepted": true} ou {"accepted": false, "reason": "explication courte en français"}`; + + try { + const response = await openai.chat.completions.create({ + model: "gpt-5-mini", + max_completion_tokens: 200, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: `Idée à analyser : "${content}"` }, + ], + }); + + const raw = response.choices[0]?.message?.content ?? '{"accepted": false, "reason": "Erreur d\'analyse"}'; + const result = JSON.parse(raw) as FilterResult; + return result; + } catch (err) { + logger.error({ err }, "Error filtering idea"); + return { accepted: false, reason: "Erreur interne de filtrage" }; + } +} + +export async function synthesizeIdeas(ideas: string[]): Promise { + if (ideas.length === 0) { + return "Aucune idée n'a encore été soumise. Soyez le premier à partager votre vision pour une société meilleure."; + } + + const systemPrompt = `Tu es un synthétiseur démocratique. Tu reçois une liste d'idées politiques citoyennes filtrées et validées. +Ta mission : créer UN texte synthétique, éloquent et inspirant qui capture l'essence collective de ces idées. + +Ce texte est "La Voix du Peuple" — il doit : +- Être écrit à la première personne du pluriel (nous, notre, nos) +- Capturer les thèmes communs et les aspirations partagées +- Être poétique mais concret, inspirant mais ancré dans la réalité +- Faire environ 3-5 paragraphes +- Commencer par "Nous, le peuple, ..." +- Respecter la diversité des idées sans en ignorer aucune +- Être rédigé en français + +NE PAS mentionner les idées individuellement, mais les fondre dans une vision collective cohérente.`; + + const ideasText = ideas.map((idea, i) => `${i + 1}. ${idea}`).join("\n"); + + try { + const response = await openai.chat.completions.create({ + model: "gpt-5.2", + max_completion_tokens: 1000, + messages: [ + { role: "system", content: systemPrompt }, + { + role: "user", + content: `Voici les idées citoyennes à synthétiser :\n\n${ideasText}\n\nRédige "La Voix du Peuple".`, + }, + ], + }); + + return response.choices[0]?.message?.content ?? "Synthèse en cours..."; + } catch (err) { + logger.error({ err }, "Error synthesizing ideas"); + return "La synthèse est temporairement indisponible. Vos idées ont été enregistrées."; + } +} diff --git a/artifacts/api-server/src/routes/ideas.ts b/artifacts/api-server/src/routes/ideas.ts new file mode 100644 index 0000000..0607f42 --- /dev/null +++ b/artifacts/api-server/src/routes/ideas.ts @@ -0,0 +1,100 @@ +import { Router, type IRouter } from "express"; +import { eq, count, and } from "drizzle-orm"; +import { db, ideasTable, synthesisTable } from "@workspace/db"; +import { SubmitIdeaBody } from "@workspace/api-zod"; +import { filterIdea, synthesizeIdeas } from "../lib/ai-agent"; +import { logger } from "../lib/logger"; + +const router: IRouter = Router(); + +router.get("/ideas", async (_req, res): Promise => { + const ideas = await db + .select() + .from(ideasTable) + .where(eq(ideasTable.accepted, true)) + .orderBy(ideasTable.createdAt); + res.json(ideas); +}); + +router.get("/ideas/stats", async (_req, res): Promise => { + const [totalRow] = await db.select({ value: count() }).from(ideasTable); + const [acceptedRow] = await db + .select({ value: count() }) + .from(ideasTable) + .where(eq(ideasTable.accepted, true)); + const [rejectedRow] = await db + .select({ value: count() }) + .from(ideasTable) + .where(eq(ideasTable.accepted, false)); + + res.json({ + total: Number(totalRow?.value ?? 0), + accepted: Number(acceptedRow?.value ?? 0), + rejected: Number(rejectedRow?.value ?? 0), + }); +}); + +router.post("/ideas", async (req, res): Promise => { + const parsed = SubmitIdeaBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "validation_error", message: parsed.error.message }); + return; + } + + const { content, author } = parsed.data; + + req.log.info({ contentLength: content.length }, "Filtering new idea"); + + const filterResult = await filterIdea(content); + + const [idea] = await db + .insert(ideasTable) + .values({ + content, + author: author ?? null, + accepted: filterResult.accepted, + rejectionReason: filterResult.reason ?? null, + }) + .returning(); + + if (filterResult.accepted) { + triggerSynthesisUpdate().catch((err) => { + logger.error({ err }, "Background synthesis update failed"); + }); + } + + res.status(201).json({ + id: idea!.id, + accepted: filterResult.accepted, + reason: filterResult.reason, + idea: idea, + }); +}); + +async function triggerSynthesisUpdate(): Promise { + const ideas = await db + .select({ content: ideasTable.content }) + .from(ideasTable) + .where(eq(ideasTable.accepted, true)) + .orderBy(ideasTable.createdAt); + + const ideaTexts = ideas.map((i) => i.content); + const synthesizedText = await synthesizeIdeas(ideaTexts); + + const existing = await db.select().from(synthesisTable).limit(1); + + if (existing.length > 0) { + await db + .update(synthesisTable) + .set({ text: synthesizedText, ideaCount: ideaTexts.length }); + } else { + await db.insert(synthesisTable).values({ + text: synthesizedText, + ideaCount: ideaTexts.length, + }); + } + + logger.info({ ideaCount: ideaTexts.length }, "Synthesis updated"); +} + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77a..c7cc355 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,8 +1,12 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health"; +import ideasRouter from "./ideas"; +import synthesisRouter from "./synthesis"; const router: IRouter = Router(); router.use(healthRouter); +router.use(ideasRouter); +router.use(synthesisRouter); export default router; diff --git a/artifacts/api-server/src/routes/synthesis.ts b/artifacts/api-server/src/routes/synthesis.ts new file mode 100644 index 0000000..369ac5a --- /dev/null +++ b/artifacts/api-server/src/routes/synthesis.ts @@ -0,0 +1,25 @@ +import { Router, type IRouter } from "express"; +import { db, synthesisTable } from "@workspace/db"; + +const router: IRouter = Router(); + +router.get("/synthesis", async (_req, res): Promise => { + const [synthesis] = await db.select().from(synthesisTable).limit(1); + + if (!synthesis) { + res.json({ + text: "Aucune idée n'a encore été soumise. Soyez le premier à partager votre vision pour une société meilleure.", + ideaCount: 0, + updatedAt: null, + }); + return; + } + + res.json({ + text: synthesis.text, + ideaCount: synthesis.ideaCount, + updatedAt: synthesis.updatedAt, + }); +}); + +export default router; diff --git a/artifacts/voix-du-peuple/.replit-artifact/artifact.toml b/artifacts/voix-du-peuple/.replit-artifact/artifact.toml new file mode 100644 index 0000000..e17e1e6 --- /dev/null +++ b/artifacts/voix-du-peuple/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "La Voix du Peuple" +version = "1.0.0" +id = "artifacts/voix-du-peuple" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 20108 + +[services.development] +run = "pnpm --filter @workspace/voix-du-peuple run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/voix-du-peuple", "run", "build" ] +publicDir = "artifacts/voix-du-peuple/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "20108" +BASE_PATH = "/" diff --git a/artifacts/voix-du-peuple/components.json b/artifacts/voix-du-peuple/components.json new file mode 100644 index 0000000..3ff62cf --- /dev/null +++ b/artifacts/voix-du-peuple/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/voix-du-peuple/index.html b/artifacts/voix-du-peuple/index.html new file mode 100644 index 0000000..828bd92 --- /dev/null +++ b/artifacts/voix-du-peuple/index.html @@ -0,0 +1,16 @@ + + + + + + La Voix du Peuple + + + + + + +
+ + + diff --git a/artifacts/voix-du-peuple/package.json b/artifacts/voix-du-peuple/package.json new file mode 100644 index 0000000..0c32cf4 --- /dev/null +++ b/artifacts/voix-du-peuple/package.json @@ -0,0 +1,77 @@ +{ + "name": "@workspace/voix-du-peuple", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/voix-du-peuple/public/favicon.svg b/artifacts/voix-du-peuple/public/favicon.svg new file mode 100644 index 0000000..4373d3c --- /dev/null +++ b/artifacts/voix-du-peuple/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/voix-du-peuple/public/opengraph.jpg b/artifacts/voix-du-peuple/public/opengraph.jpg new file mode 100644 index 0000000..bce6793 Binary files /dev/null and b/artifacts/voix-du-peuple/public/opengraph.jpg differ diff --git a/artifacts/voix-du-peuple/src/App.tsx b/artifacts/voix-du-peuple/src/App.tsx new file mode 100644 index 0000000..66c9b49 --- /dev/null +++ b/artifacts/voix-du-peuple/src/App.tsx @@ -0,0 +1,62 @@ +import { Switch, Route, Router as WouterRouter, Link } from "wouter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import NotFound from "@/pages/not-found"; +import Home from "@/pages/home"; +import About from "@/pages/about"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +function Navbar() { + return ( +
+
+ + La Voix du Peuple + + +
+
+ ); +} + +function Router() { + return ( +
+ +
+ + + + + +
+
+ ); +} + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/artifacts/voix-du-peuple/src/components/ui/accordion.tsx b/artifacts/voix-du-peuple/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e1797c9 --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/artifacts/voix-du-peuple/src/components/ui/alert-dialog.tsx b/artifacts/voix-du-peuple/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..fa2b442 --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/artifacts/voix-du-peuple/src/components/ui/alert.tsx b/artifacts/voix-du-peuple/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/artifacts/voix-du-peuple/src/components/ui/aspect-ratio.tsx b/artifacts/voix-du-peuple/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/artifacts/voix-du-peuple/src/components/ui/avatar.tsx b/artifacts/voix-du-peuple/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/artifacts/voix-du-peuple/src/components/ui/badge.tsx b/artifacts/voix-du-peuple/src/components/ui/badge.tsx new file mode 100644 index 0000000..3f03665 --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // @replit + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate ", + { + variants: { + variant: { + default: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: + // @replit no hover because we use hover-elevate + "border-transparent bg-secondary text-secondary-foreground", + destructive: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + // @replit shadow-xs" - use badge outline variable + outline: "text-foreground border [border-color:var(--badge-outline)]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/artifacts/voix-du-peuple/src/components/ui/breadcrumb.tsx b/artifacts/voix-du-peuple/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>