From fe70005a86f7095d5e60f104bd6a3e22f50c2dac Mon Sep 17 00:00:00 2001 From: Yanis Date: Fri, 3 Apr 2026 11:22:01 +0200 Subject: [PATCH] add vineye-admin dashboard (Next.js) Admin panel for VinEye with dashboard, users, diseases, guides, alerts management. Stack: Next.js App Router + Prisma + PostgreSQL + better-auth. Co-Authored-By: Claude Opus 4.6 (1M context) --- vineye-admin/.gitignore | 43 + vineye-admin/AGENTS.md | 5 + vineye-admin/CLAUDE.md | 1 + vineye-admin/README.md | 36 + .../app/(admin)/alerts/[id]/edit/page.tsx | 32 + .../app/(admin)/alerts/alerts-client.tsx | 193 + vineye-admin/app/(admin)/alerts/new/page.tsx | 5 + vineye-admin/app/(admin)/alerts/page.tsx | 12 + .../(admin)/dashboard/dashboard-client.tsx | 142 + vineye-admin/app/(admin)/dashboard/page.tsx | 74 + .../app/(admin)/diseases/[id]/edit/page.tsx | 41 + .../app/(admin)/diseases/diseases-client.tsx | 267 + .../app/(admin)/diseases/new/page.tsx | 5 + vineye-admin/app/(admin)/diseases/page.tsx | 23 + .../app/(admin)/guides/[id]/edit/page.tsx | 35 + .../app/(admin)/guides/guides-client.tsx | 180 + vineye-admin/app/(admin)/guides/new/page.tsx | 5 + vineye-admin/app/(admin)/guides/page.tsx | 12 + vineye-admin/app/(admin)/layout.tsx | 45 + vineye-admin/app/(admin)/users/[id]/page.tsx | 43 + .../(admin)/users/[id]/user-detail-client.tsx | 207 + vineye-admin/app/(admin)/users/page.tsx | 23 + .../app/(admin)/users/users-client.tsx | 182 + vineye-admin/app/(auth)/layout.tsx | 10 + vineye-admin/app/(auth)/login/page.tsx | 138 + vineye-admin/app/api/alerts/[id]/route.ts | 70 + vineye-admin/app/api/alerts/route.ts | 44 + vineye-admin/app/api/auth/[...all]/route.ts | 4 + vineye-admin/app/api/diseases/[id]/route.ts | 81 + vineye-admin/app/api/diseases/route.ts | 53 + vineye-admin/app/api/guides/[id]/route.ts | 81 + vineye-admin/app/api/guides/route.ts | 51 + vineye-admin/app/api/scans/[id]/route.ts | 29 + vineye-admin/app/api/scans/route.ts | 90 + vineye-admin/app/api/users/[id]/route.ts | 96 + vineye-admin/app/api/users/route.ts | 41 + vineye-admin/app/favicon.ico | Bin 0 -> 25931 bytes vineye-admin/app/globals.css | 207 + vineye-admin/app/layout.tsx | 49 + vineye-admin/app/page.tsx | 5 + vineye-admin/components.json | 25 + vineye-admin/components/admin/alert-form.tsx | 191 + .../components/admin/delete-dialog.tsx | 72 + .../components/admin/disease-form.tsx | 360 + vineye-admin/components/admin/guide-form.tsx | 214 + vineye-admin/components/admin/header.tsx | 81 + vineye-admin/components/admin/sidebar.tsx | 193 + vineye-admin/components/admin/stat-card.tsx | 38 + vineye-admin/components/ui/alert-dialog.tsx | 187 + vineye-admin/components/ui/avatar.tsx | 109 + vineye-admin/components/ui/badge.tsx | 52 + vineye-admin/components/ui/button.tsx | 60 + vineye-admin/components/ui/card.tsx | 103 + vineye-admin/components/ui/dialog.tsx | 160 + vineye-admin/components/ui/dropdown-menu.tsx | 268 + vineye-admin/components/ui/input.tsx | 20 + vineye-admin/components/ui/label.tsx | 20 + vineye-admin/components/ui/select.tsx | 201 + vineye-admin/components/ui/separator.tsx | 25 + vineye-admin/components/ui/sheet.tsx | 138 + vineye-admin/components/ui/skeleton.tsx | 13 + vineye-admin/components/ui/sonner.tsx | 49 + vineye-admin/components/ui/switch.tsx | 32 + vineye-admin/components/ui/table.tsx | 116 + vineye-admin/components/ui/tabs.tsx | 82 + vineye-admin/components/ui/textarea.tsx | 18 + vineye-admin/components/ui/tooltip.tsx | 66 + vineye-admin/eslint.config.mjs | 18 + vineye-admin/lib/auth-client.ts | 9 + vineye-admin/lib/auth-guard.ts | 28 + vineye-admin/lib/auth.ts | 31 + vineye-admin/lib/prisma.ts | 17 + vineye-admin/lib/utils.ts | 25 + vineye-admin/lib/validations.ts | 65 + vineye-admin/middleware.ts | 30 + vineye-admin/next.config.ts | 12 + vineye-admin/package.json | 56 + vineye-admin/pnpm-lock.yaml | 7861 +++++++++++++++++ vineye-admin/pnpm-workspace.yaml | 3 + vineye-admin/postcss.config.mjs | 7 + vineye-admin/prisma.config.ts | 12 + .../20260402210031_init/migration.sql | 208 + .../prisma/migrations/migration_lock.toml | 3 + vineye-admin/prisma/schema.prisma | 193 + vineye-admin/prisma/seed.ts | 421 + vineye-admin/public/file.svg | 1 + vineye-admin/public/globe.svg | 1 + vineye-admin/public/next.svg | 1 + vineye-admin/public/vercel.svg | 1 + vineye-admin/public/window.svg | 1 + vineye-admin/tsconfig.json | 34 + 91 files changed, 14591 insertions(+) create mode 100644 vineye-admin/.gitignore create mode 100644 vineye-admin/AGENTS.md create mode 100644 vineye-admin/CLAUDE.md create mode 100644 vineye-admin/README.md create mode 100644 vineye-admin/app/(admin)/alerts/[id]/edit/page.tsx create mode 100644 vineye-admin/app/(admin)/alerts/alerts-client.tsx create mode 100644 vineye-admin/app/(admin)/alerts/new/page.tsx create mode 100644 vineye-admin/app/(admin)/alerts/page.tsx create mode 100644 vineye-admin/app/(admin)/dashboard/dashboard-client.tsx create mode 100644 vineye-admin/app/(admin)/dashboard/page.tsx create mode 100644 vineye-admin/app/(admin)/diseases/[id]/edit/page.tsx create mode 100644 vineye-admin/app/(admin)/diseases/diseases-client.tsx create mode 100644 vineye-admin/app/(admin)/diseases/new/page.tsx create mode 100644 vineye-admin/app/(admin)/diseases/page.tsx create mode 100644 vineye-admin/app/(admin)/guides/[id]/edit/page.tsx create mode 100644 vineye-admin/app/(admin)/guides/guides-client.tsx create mode 100644 vineye-admin/app/(admin)/guides/new/page.tsx create mode 100644 vineye-admin/app/(admin)/guides/page.tsx create mode 100644 vineye-admin/app/(admin)/layout.tsx create mode 100644 vineye-admin/app/(admin)/users/[id]/page.tsx create mode 100644 vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx create mode 100644 vineye-admin/app/(admin)/users/page.tsx create mode 100644 vineye-admin/app/(admin)/users/users-client.tsx create mode 100644 vineye-admin/app/(auth)/layout.tsx create mode 100644 vineye-admin/app/(auth)/login/page.tsx create mode 100644 vineye-admin/app/api/alerts/[id]/route.ts create mode 100644 vineye-admin/app/api/alerts/route.ts create mode 100644 vineye-admin/app/api/auth/[...all]/route.ts create mode 100644 vineye-admin/app/api/diseases/[id]/route.ts create mode 100644 vineye-admin/app/api/diseases/route.ts create mode 100644 vineye-admin/app/api/guides/[id]/route.ts create mode 100644 vineye-admin/app/api/guides/route.ts create mode 100644 vineye-admin/app/api/scans/[id]/route.ts create mode 100644 vineye-admin/app/api/scans/route.ts create mode 100644 vineye-admin/app/api/users/[id]/route.ts create mode 100644 vineye-admin/app/api/users/route.ts create mode 100644 vineye-admin/app/favicon.ico create mode 100644 vineye-admin/app/globals.css create mode 100644 vineye-admin/app/layout.tsx create mode 100644 vineye-admin/app/page.tsx create mode 100644 vineye-admin/components.json create mode 100644 vineye-admin/components/admin/alert-form.tsx create mode 100644 vineye-admin/components/admin/delete-dialog.tsx create mode 100644 vineye-admin/components/admin/disease-form.tsx create mode 100644 vineye-admin/components/admin/guide-form.tsx create mode 100644 vineye-admin/components/admin/header.tsx create mode 100644 vineye-admin/components/admin/sidebar.tsx create mode 100644 vineye-admin/components/admin/stat-card.tsx create mode 100644 vineye-admin/components/ui/alert-dialog.tsx create mode 100644 vineye-admin/components/ui/avatar.tsx create mode 100644 vineye-admin/components/ui/badge.tsx create mode 100644 vineye-admin/components/ui/button.tsx create mode 100644 vineye-admin/components/ui/card.tsx create mode 100644 vineye-admin/components/ui/dialog.tsx create mode 100644 vineye-admin/components/ui/dropdown-menu.tsx create mode 100644 vineye-admin/components/ui/input.tsx create mode 100644 vineye-admin/components/ui/label.tsx create mode 100644 vineye-admin/components/ui/select.tsx create mode 100644 vineye-admin/components/ui/separator.tsx create mode 100644 vineye-admin/components/ui/sheet.tsx create mode 100644 vineye-admin/components/ui/skeleton.tsx create mode 100644 vineye-admin/components/ui/sonner.tsx create mode 100644 vineye-admin/components/ui/switch.tsx create mode 100644 vineye-admin/components/ui/table.tsx create mode 100644 vineye-admin/components/ui/tabs.tsx create mode 100644 vineye-admin/components/ui/textarea.tsx create mode 100644 vineye-admin/components/ui/tooltip.tsx create mode 100644 vineye-admin/eslint.config.mjs create mode 100644 vineye-admin/lib/auth-client.ts create mode 100644 vineye-admin/lib/auth-guard.ts create mode 100644 vineye-admin/lib/auth.ts create mode 100644 vineye-admin/lib/prisma.ts create mode 100644 vineye-admin/lib/utils.ts create mode 100644 vineye-admin/lib/validations.ts create mode 100644 vineye-admin/middleware.ts create mode 100644 vineye-admin/next.config.ts create mode 100644 vineye-admin/package.json create mode 100644 vineye-admin/pnpm-lock.yaml create mode 100644 vineye-admin/pnpm-workspace.yaml create mode 100644 vineye-admin/postcss.config.mjs create mode 100644 vineye-admin/prisma.config.ts create mode 100644 vineye-admin/prisma/migrations/20260402210031_init/migration.sql create mode 100644 vineye-admin/prisma/migrations/migration_lock.toml create mode 100644 vineye-admin/prisma/schema.prisma create mode 100644 vineye-admin/prisma/seed.ts create mode 100644 vineye-admin/public/file.svg create mode 100644 vineye-admin/public/globe.svg create mode 100644 vineye-admin/public/next.svg create mode 100644 vineye-admin/public/vercel.svg create mode 100644 vineye-admin/public/window.svg create mode 100644 vineye-admin/tsconfig.json diff --git a/vineye-admin/.gitignore b/vineye-admin/.gitignore new file mode 100644 index 0000000..45254b6 --- /dev/null +++ b/vineye-admin/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/app/generated/prisma diff --git a/vineye-admin/AGENTS.md b/vineye-admin/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/vineye-admin/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/vineye-admin/CLAUDE.md b/vineye-admin/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/vineye-admin/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/vineye-admin/README.md b/vineye-admin/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/vineye-admin/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/vineye-admin/app/(admin)/alerts/[id]/edit/page.tsx b/vineye-admin/app/(admin)/alerts/[id]/edit/page.tsx new file mode 100644 index 0000000..54445d3 --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/[id]/edit/page.tsx @@ -0,0 +1,32 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import AlertForm from "@/components/admin/alert-form"; + +export default async function EditAlertPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const alert = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!alert) notFound(); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/alerts/alerts-client.tsx b/vineye-admin/app/(admin)/alerts/alerts-client.tsx new file mode 100644 index 0000000..95e34ba --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/alerts-client.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Plus, Search, Pencil } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import DeleteDialog from "@/components/admin/delete-dialog"; +import { toast } from "sonner"; +import { cn, formatDateShort } from "@/lib/utils"; + +type Alert = { + id: string; + title: string; + type: string; + region: string; + active: boolean; + activeFrom: Date; + activeTo: Date | null; + createdAt: Date; +}; + +const TYPE_STYLES: Record = { + WARNING: "bg-gold/10 text-gold border-gold/20", + INFO: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", + DANGER: "bg-wine/10 text-[#FB7185] border-wine/20", +}; + +export default function AlertsClient({ alerts }: { alerts: Alert[] }) { + const router = useRouter(); + const [search, setSearch] = useState(""); + + const filtered = alerts.filter( + (a) => + a.title.toLowerCase().includes(search.toLowerCase()) || + a.region.toLowerCase().includes(search.toLowerCase()) + ); + + async function handleToggleActive(id: string, active: boolean) { + try { + const alert = alerts.find((a) => a.id === id); + if (!alert) return; + const res = await fetch(`/api/alerts/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...alert, active }), + }); + if (!res.ok) throw new Error(); + toast.success(active ? "Alerte activee" : "Alerte desactivee"); + router.refresh(); + } catch { + toast.error("Erreur"); + } + } + + async function handleDelete(id: string) { + const res = await fetch(`/api/alerts/${id}`, { method: "DELETE" }); + if (!res.ok) { + toast.error("Erreur lors de la suppression"); + return; + } + toast.success("Alerte supprimee"); + router.refresh(); + } + + return ( +
+
+
+

+ Alertes saisonnieres +

+

{alerts.length} alertes

+
+ + + Ajouter + +
+ +
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+ +
+ + + + Titre + Type + Region + Periode + Active + Actions + + + + {filtered.map((alert) => ( + + {alert.title} + + + {alert.type} + + + {alert.region} + + {formatDateShort(alert.activeFrom)} + {alert.activeTo && ` — ${formatDateShort(alert.activeTo)}`} + + + handleToggleActive(alert.id, checked)} + /> + + +
+ + + + handleDelete(alert.id)} + /> +
+
+
+ ))} +
+
+ {filtered.length === 0 && ( +

Aucune alerte trouvee

+ )} +
+ + {/* Mobile */} +
+ {filtered.map((alert) => ( +
+
+

{alert.title}

+ + + +
+
+ + {alert.type} + + {alert.region} + handleToggleActive(alert.id, checked)} + className="ml-auto" + /> +
+
+ ))} +
+
+ ); +} diff --git a/vineye-admin/app/(admin)/alerts/new/page.tsx b/vineye-admin/app/(admin)/alerts/new/page.tsx new file mode 100644 index 0000000..de599b6 --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/new/page.tsx @@ -0,0 +1,5 @@ +import AlertForm from "@/components/admin/alert-form"; + +export default function NewAlertPage() { + return ; +} diff --git a/vineye-admin/app/(admin)/alerts/page.tsx b/vineye-admin/app/(admin)/alerts/page.tsx new file mode 100644 index 0000000..05962e9 --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/page.tsx @@ -0,0 +1,12 @@ +import { prisma } from "@/lib/prisma"; +import AlertsClient from "./alerts-client"; + +export const dynamic = "force-dynamic"; + +export default async function AlertsPage() { + const alerts = await prisma.seasonAlert.findMany({ + orderBy: { createdAt: "desc" }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/dashboard/dashboard-client.tsx b/vineye-admin/app/(admin)/dashboard/dashboard-client.tsx new file mode 100644 index 0000000..e6ae627 --- /dev/null +++ b/vineye-admin/app/(admin)/dashboard/dashboard-client.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Users, ScanLine, Bug, AlertTriangle } from "lucide-react"; +import StatCard from "@/components/admin/stat-card"; +import { Badge } from "@/components/ui/badge"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; + +interface DashboardProps { + stats: { + totalUsers: number; + scansThisMonth: number; + totalDiseases: number; + activeAlerts: number; + }; + recentScans: { + id: string; + userName: string; + diseaseName: string; + confidence: number; + date: string; + }[]; + topDiseases: { name: string; count: number }[]; +} + +export default function DashboardClient({ stats, recentScans, topDiseases }: DashboardProps) { + const today = format(new Date(), "EEEE d MMMM yyyy", { locale: fr }); + const maxCount = topDiseases[0]?.count || 1; + + return ( +
+ {/* Header */} +
+

+ Tableau de bord +

+

{today}

+
+ + {/* Stats */} +
+ + + + +
+ +
+ {/* Recent scans */} +
+
+

Scans recents

+
+
+ {recentScans.length === 0 ? ( +

+ Aucun scan pour le moment +

+ ) : ( + recentScans.map((scan) => ( +
+
+

{scan.diseaseName}

+

{scan.userName}

+
+
+ + {scan.confidence}% + + {scan.date} +
+
+ )) + )} +
+
+ + {/* Top diseases */} +
+
+

Maladies les plus detectees

+
+
+ {topDiseases.length === 0 ? ( +

+ Pas assez de donnees +

+ ) : ( + topDiseases.map((disease, i) => ( +
+
+
+ + {String(i + 1).padStart(2, "0")} + + {disease.name} +
+ + {disease.count} + +
+
+
+
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/vineye-admin/app/(admin)/dashboard/page.tsx b/vineye-admin/app/(admin)/dashboard/page.tsx new file mode 100644 index 0000000..8507c9a --- /dev/null +++ b/vineye-admin/app/(admin)/dashboard/page.tsx @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/prisma"; +import { formatDate } from "@/lib/utils"; +import DashboardClient from "./dashboard-client"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const [totalUsers, totalDiseases, activeAlerts, scansThisMonth, recentScans, topDiseases] = + await Promise.all([ + prisma.user.count(), + prisma.disease.count(), + prisma.seasonAlert.count({ where: { active: true } }), + prisma.scan.count({ + where: { + createdAt: { + gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + }, + }, + }), + prisma.scan.findMany({ + select: { + id: true, + confidence: true, + createdAt: true, + user: { select: { name: true } }, + disease: { select: { name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 8, + }), + prisma.scan + .groupBy({ + by: ["diseaseId"], + _count: { id: true }, + where: { diseaseId: { not: null } }, + orderBy: { _count: { id: "desc" } }, + take: 5, + }) + .then(async (stats) => { + const ids = stats + .map((s) => s.diseaseId) + .filter((id): id is string => id !== null); + const diseases = await prisma.disease.findMany({ + where: { id: { in: ids } }, + select: { id: true, name: true }, + }); + return stats.map((stat) => ({ + name: diseases.find((d) => d.id === stat.diseaseId)?.name || "Inconnu", + count: stat._count.id, + })); + }), + ]); + + const formattedScans = recentScans.map((scan) => ({ + id: scan.id, + userName: scan.user.name, + diseaseName: scan.disease?.name || "Non identifie", + confidence: Math.round(scan.confidence * 100), + date: formatDate(scan.createdAt), + })); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/diseases/[id]/edit/page.tsx b/vineye-admin/app/(admin)/diseases/[id]/edit/page.tsx new file mode 100644 index 0000000..d07c6eb --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/[id]/edit/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import DiseaseForm from "@/components/admin/disease-form"; + +export default async function EditDiseasePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const disease = await prisma.disease.findUnique({ where: { id } }); + if (!disease) notFound(); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/diseases/diseases-client.tsx b/vineye-admin/app/(admin)/diseases/diseases-client.tsx new file mode 100644 index 0000000..70247ed --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/diseases-client.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Plus, Search, Pencil } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Switch } from "@/components/ui/switch"; +import DeleteDialog from "@/components/admin/delete-dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +type Disease = { + id: string; + name: string; + scientificName: string; + slug: string; + type: string; + severity: string; + published: boolean; + createdAt: Date; + _count: { scans: number }; +}; + +const TYPE_LABELS: Record = { + FUNGAL: "Fongique", + BACTERIAL: "Bacterien", + PEST: "Ravageur", + ABIOTIC: "Carence", +}; + +const TYPE_STYLES: Record = { + FUNGAL: "bg-vine/10 text-vine border-vine/20", + BACTERIAL: "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20", + PEST: "bg-gold/10 text-gold border-gold/20", + ABIOTIC: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", +}; + +const SEVERITY_STYLES: Record = { + HIGH: "bg-wine/10 text-[#FB7185] border-wine/20", + MEDIUM: "bg-gold/10 text-gold border-gold/20", + LOW: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", +}; + +const SEVERITY_LABELS: Record = { + HIGH: "Critique", + MEDIUM: "Modere", + LOW: "Faible", +}; + +export default function DiseasesClient({ diseases }: { diseases: Disease[] }) { + const router = useRouter(); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("ALL"); + + const filtered = diseases.filter((d) => { + const matchSearch = + d.name.toLowerCase().includes(search.toLowerCase()) || + d.scientificName.toLowerCase().includes(search.toLowerCase()); + const matchType = typeFilter === "ALL" || d.type === typeFilter; + return matchSearch && matchType; + }); + + async function handleTogglePublish(id: string, published: boolean) { + try { + const disease = diseases.find((d) => d.id === id); + if (!disease) return; + + const res = await fetch(`/api/diseases/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...disease, + published, + symptoms: [], + description: "placeholder", + treatment: "placeholder", + season: "placeholder", + }), + }); + + if (!res.ok) throw new Error(); + toast.success(published ? "Maladie publiee" : "Maladie depubliee"); + router.refresh(); + } catch { + toast.error("Erreur lors de la mise a jour"); + } + } + + async function handleDelete(id: string) { + const res = await fetch(`/api/diseases/${id}`, { method: "DELETE" }); + if (!res.ok) { + toast.error("Erreur lors de la suppression"); + return; + } + toast.success("Maladie supprimee"); + router.refresh(); + } + + return ( +
+ {/* Header */} +
+
+

+ Maladies de la vigne +

+

{diseases.length} maladies repertoriees

+
+ + + Ajouter + +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+
+ {["ALL", "FUNGAL", "BACTERIAL", "PEST", "ABIOTIC"].map((type) => ( + + ))} +
+
+ + {/* Table (desktop) */} +
+ + + + Nom + Type + Severite + Scans + Publie + Actions + + + + {filtered.map((disease) => ( + + +
+

{disease.name}

+ {disease.scientificName && ( +

{disease.scientificName}

+ )} +
+
+ + + {TYPE_LABELS[disease.type]} + + + + + {SEVERITY_LABELS[disease.severity]} + + + + {disease._count.scans} + + + handleTogglePublish(disease.id, checked)} + /> + + +
+ + + + handleDelete(disease.id)} + /> +
+
+
+ ))} +
+
+ {filtered.length === 0 && ( +

+ Aucune maladie trouvee +

+ )} +
+ + {/* Cards (mobile) */} +
+ {filtered.map((disease) => ( +
+
+
+

{disease.name}

+ {disease.scientificName && ( +

{disease.scientificName}

+ )} +
+ + + +
+
+ + {TYPE_LABELS[disease.type]} + + + {SEVERITY_LABELS[disease.severity]} + + {disease._count.scans} scans +
+
+ ))} +
+
+ ); +} diff --git a/vineye-admin/app/(admin)/diseases/new/page.tsx b/vineye-admin/app/(admin)/diseases/new/page.tsx new file mode 100644 index 0000000..38200dd --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/new/page.tsx @@ -0,0 +1,5 @@ +import DiseaseForm from "@/components/admin/disease-form"; + +export default function NewDiseasePage() { + return ; +} diff --git a/vineye-admin/app/(admin)/diseases/page.tsx b/vineye-admin/app/(admin)/diseases/page.tsx new file mode 100644 index 0000000..b71f530 --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/page.tsx @@ -0,0 +1,23 @@ +import { prisma } from "@/lib/prisma"; +import DiseasesClient from "./diseases-client"; + +export const dynamic = "force-dynamic"; + +export default async function DiseasesPage() { + const diseases = await prisma.disease.findMany({ + orderBy: { createdAt: "desc" }, + select: { + id: true, + name: true, + scientificName: true, + slug: true, + type: true, + severity: true, + published: true, + createdAt: true, + _count: { select: { scans: true } }, + }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/guides/[id]/edit/page.tsx b/vineye-admin/app/(admin)/guides/[id]/edit/page.tsx new file mode 100644 index 0000000..fa8bdd5 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/[id]/edit/page.tsx @@ -0,0 +1,35 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import GuideForm from "@/components/admin/guide-form"; + +export default async function EditGuidePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const guide = await prisma.guide.findUnique({ where: { id } }); + if (!guide) notFound(); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/guides/guides-client.tsx b/vineye-admin/app/(admin)/guides/guides-client.tsx new file mode 100644 index 0000000..a38ce62 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/guides-client.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Plus, Search, Pencil } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Switch } from "@/components/ui/switch"; +import DeleteDialog from "@/components/admin/delete-dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +type Guide = { + id: string; + title: string; + slug: string; + subtitle: string; + category: string; + order: number; + published: boolean; + createdAt: Date; +}; + +const CATEGORY_STYLES: Record = { + diagnostic: "bg-vine/10 text-vine border-vine/20", + traitement: "bg-gold/10 text-gold border-gold/20", + cepages: "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20", + general: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", +}; + +export default function GuidesClient({ guides }: { guides: Guide[] }) { + const router = useRouter(); + const [search, setSearch] = useState(""); + + const filtered = guides.filter( + (g) => + g.title.toLowerCase().includes(search.toLowerCase()) || + g.category.toLowerCase().includes(search.toLowerCase()) + ); + + async function handleDelete(id: string) { + const res = await fetch(`/api/guides/${id}`, { method: "DELETE" }); + if (!res.ok) { + toast.error("Erreur lors de la suppression"); + return; + } + toast.success("Guide supprime"); + router.refresh(); + } + + return ( +
+
+
+

+ Guides +

+

{guides.length} guides

+
+ + + Ajouter + +
+ +
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+ +
+ + + + Titre + Categorie + Ordre + Publie + Actions + + + + {filtered.map((guide) => ( + + +
+

{guide.title}

+

{guide.subtitle}

+
+
+ + + {guide.category} + + + + {guide.order} + + + + + +
+ + + + handleDelete(guide.id)} + /> +
+
+
+ ))} +
+
+ {filtered.length === 0 && ( +

Aucun guide trouve

+ )} +
+ + {/* Mobile cards */} +
+ {filtered.map((guide) => ( +
+
+
+

{guide.title}

+

{guide.subtitle}

+
+ + + +
+
+ + {guide.category} + + #{guide.order} +
+
+ ))} +
+
+ ); +} diff --git a/vineye-admin/app/(admin)/guides/new/page.tsx b/vineye-admin/app/(admin)/guides/new/page.tsx new file mode 100644 index 0000000..12bf199 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/new/page.tsx @@ -0,0 +1,5 @@ +import GuideForm from "@/components/admin/guide-form"; + +export default function NewGuidePage() { + return ; +} diff --git a/vineye-admin/app/(admin)/guides/page.tsx b/vineye-admin/app/(admin)/guides/page.tsx new file mode 100644 index 0000000..c480763 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/page.tsx @@ -0,0 +1,12 @@ +import { prisma } from "@/lib/prisma"; +import GuidesClient from "./guides-client"; + +export const dynamic = "force-dynamic"; + +export default async function GuidesPage() { + const guides = await prisma.guide.findMany({ + orderBy: { order: "asc" }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/layout.tsx b/vineye-admin/app/(admin)/layout.tsx new file mode 100644 index 0000000..27f4b05 --- /dev/null +++ b/vineye-admin/app/(admin)/layout.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import { useSession } from "@/lib/auth-client"; +import Sidebar from "@/components/admin/sidebar"; +import Header from "@/components/admin/header"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const { data: session } = useSession(); + + const userName = session?.user?.name || "Admin"; + const userEmail = session?.user?.email || ""; + + return ( +
+ {/* Desktop sidebar */} +
+ setCollapsed(!collapsed)} + userName={userName} + userEmail={userEmail} + /> +
+ + {/* Mobile sidebar */} + + + + + + + {/* Main content */} +
+
setSidebarOpen(true)} userName={userName} /> +
+
{children}
+
+
+
+ ); +} diff --git a/vineye-admin/app/(admin)/users/[id]/page.tsx b/vineye-admin/app/(admin)/users/[id]/page.tsx new file mode 100644 index 0000000..d83a83f --- /dev/null +++ b/vineye-admin/app/(admin)/users/[id]/page.tsx @@ -0,0 +1,43 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import UserDetailClient from "./user-detail-client"; + +export const dynamic = "force-dynamic"; + +export default async function UserDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + bannedReason: true, + createdAt: true, + scans: { + select: { + id: true, + confidence: true, + createdAt: true, + disease: { select: { name: true, severity: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 20, + }, + _count: { select: { scans: true } }, + }, + }); + + if (!user) notFound(); + + return ; +} diff --git a/vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx b/vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx new file mode 100644 index 0000000..827e3fd --- /dev/null +++ b/vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { ArrowLeft, ScanLine, Trophy, Zap, Calendar } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { formatDate } from "@/lib/utils"; + +interface UserDetailProps { + user: { + id: string; + name: string; + email: string; + role: string; + xp: number; + level: number; + banned: boolean; + bannedReason: string | null; + createdAt: Date; + scans: { + id: string; + confidence: number; + createdAt: Date; + disease: { name: string; severity: string } | null; + }[]; + _count: { scans: number }; + }; +} + +const SEVERITY_STYLES: Record = { + HIGH: "bg-wine/10 text-[#FB7185] border-wine/20", + MEDIUM: "bg-gold/10 text-gold border-gold/20", + LOW: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", +}; + +export default function UserDetailClient({ user }: UserDetailProps) { + const router = useRouter(); + + async function handleUpdate(data: Record) { + try { + const res = await fetch(`/api/users/${user.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(); + toast.success("Utilisateur mis a jour"); + router.refresh(); + } catch { + toast.error("Erreur lors de la mise a jour"); + } + } + + const STAT_ITEMS = [ + { label: "Scans", value: user._count.scans, icon: ScanLine, color: "text-vine" }, + { label: "XP", value: user.xp, icon: Zap, color: "text-gold" }, + { label: "Niveau", value: user.level, icon: Trophy, color: "text-[#A78BFA]" }, + { label: "Inscrit", value: formatDate(user.createdAt, "MMM yyyy"), icon: Calendar, color: "text-[#60A5FA]" }, + ]; + + return ( +
+
+ +

+ Utilisateur +

+
+ + {/* Profile card */} +
+
+ + + {user.name.charAt(0).toUpperCase()} + + +
+
+

{user.name}

+ + {user.role} + + {user.banned && ( + + Banni + + )} +
+

{user.email}

+

+ Inscrit le {formatDate(user.createdAt)} +

+
+
+ + {/* Stats */} +
+ {STAT_ITEMS.map((stat) => ( +
+ +

{stat.value}

+

{stat.label}

+
+ ))} +
+
+ + {/* Admin actions */} +
+

+ Actions admin +

+
+
+

Role

+

Modifier le role de l'utilisateur

+
+ +
+
+
+
+

Bannir

+

Empecher l'acces a l'application

+
+ handleUpdate({ banned })} + /> +
+
+ + {/* Scan history */} +
+
+

Historique des scans

+
+
+ {user.scans.length === 0 ? ( +

Aucun scan

+ ) : ( + user.scans.map((scan) => ( +
+
+

{scan.disease?.name || "Non identifie"}

+

{formatDate(scan.createdAt)}

+
+
+ {scan.disease?.severity && ( + + {scan.disease.severity} + + )} + + {Math.round(scan.confidence * 100)}% + +
+
+ )) + )} +
+
+
+ ); +} diff --git a/vineye-admin/app/(admin)/users/page.tsx b/vineye-admin/app/(admin)/users/page.tsx new file mode 100644 index 0000000..50e8e84 --- /dev/null +++ b/vineye-admin/app/(admin)/users/page.tsx @@ -0,0 +1,23 @@ +import { prisma } from "@/lib/prisma"; +import UsersClient from "./users-client"; + +export const dynamic = "force-dynamic"; + +export default async function UsersPage() { + const users = await prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + createdAt: true, + _count: { select: { scans: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/users/users-client.tsx b/vineye-admin/app/(admin)/users/users-client.tsx new file mode 100644 index 0000000..14ec026 --- /dev/null +++ b/vineye-admin/app/(admin)/users/users-client.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { formatDateShort } from "@/lib/utils"; + +type User = { + id: string; + name: string; + email: string; + role: string; + xp: number; + level: number; + banned: boolean; + createdAt: Date; + _count: { scans: number }; +}; + +export default function UsersClient({ users }: { users: User[] }) { + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState("ALL"); + + const filtered = users.filter((u) => { + const matchSearch = + u.name.toLowerCase().includes(search.toLowerCase()) || + u.email.toLowerCase().includes(search.toLowerCase()); + const matchRole = roleFilter === "ALL" || u.role === roleFilter; + return matchSearch && matchRole; + }); + + return ( +
+
+

+ Utilisateurs +

+

{users.length} utilisateurs

+
+ +
+
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+
+ {["ALL", "USER", "ADMIN"].map((role) => ( + + ))} +
+
+ +
+ + + + Utilisateur + Role + XP + Niveau + Scans + Inscrit + Statut + + + + {filtered.map((user) => ( + + + + + + {user.name.charAt(0).toUpperCase()} + + +
+

{user.name}

+

{user.email}

+
+ +
+ + + {user.role} + + + {user.xp} + {user.level} + {user._count.scans} + {formatDateShort(user.createdAt)} + + {user.banned ? ( + + Banni + + ) : ( + + + Actif + + )} + +
+ ))} +
+
+ {filtered.length === 0 && ( +

Aucun utilisateur trouve

+ )} +
+ + {/* Mobile cards */} +
+ {filtered.map((user) => ( + +
+
+ + + {user.name.charAt(0).toUpperCase()} + + +
+

{user.name}

+

{user.email}

+
+ + {user.role} + +
+
+ + ))} +
+
+ ); +} diff --git a/vineye-admin/app/(auth)/layout.tsx b/vineye-admin/app/(auth)/layout.tsx new file mode 100644 index 0000000..826d38c --- /dev/null +++ b/vineye-admin/app/(auth)/layout.tsx @@ -0,0 +1,10 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* Ambient glow */} +
+
+ {children} +
+ ); +} diff --git a/vineye-admin/app/(auth)/login/page.tsx b/vineye-admin/app/(auth)/login/page.tsx new file mode 100644 index 0000000..d658c21 --- /dev/null +++ b/vineye-admin/app/(auth)/login/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Grape, Loader2, Eye, EyeOff } from "lucide-react"; +import { signIn } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (loading) return; + + const trimmedEmail = email.trim().toLowerCase(); + if (!trimmedEmail || !password) { + toast.error("Veuillez remplir tous les champs"); + return; + } + + setLoading(true); + try { + await signIn.email( + { email: trimmedEmail, password }, + { + onSuccess: () => { + router.push("/dashboard"); + }, + onError: (ctx) => { + toast.error(ctx.error.message || "Identifiants incorrects"); + }, + } + ); + } catch { + toast.error("Une erreur est survenue"); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* Logo */} +
+
+ +
+

+ VinEye Admin +

+

+ Connectez-vous pour acceder au panel +

+
+ + {/* Form card */} +
+
+
+ + setEmail(e.target.value)} + placeholder="admin@vineye.app" + className="h-11 rounded-xl bg-[oklch(0.12_0.005_60)] border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40 focus:ring-vine/20 transition-colors" + autoComplete="email" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + className="h-11 rounded-xl bg-[oklch(0.12_0.005_60)] border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40 focus:ring-vine/20 pr-10 transition-colors" + autoComplete="current-password" + required + /> + +
+
+ + +
+
+ + {/* Footer */} +

+ VinEye — Detection des maladies de la vigne +

+
+ ); +} diff --git a/vineye-admin/app/api/alerts/[id]/route.ts b/vineye-admin/app/api/alerts/[id]/route.ts new file mode 100644 index 0000000..61c2757 --- /dev/null +++ b/vineye-admin/app/api/alerts/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { alertSchema } from "@/lib/validations"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const alert = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!alert) { + return Response.json({ error: "Alerte introuvable" }, { status: 404 }); + } + + return Response.json({ data: alert }); +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Alerte introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = alertSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const alert = await prisma.seasonAlert.update({ + where: { id }, + data: result.data, + }); + + return Response.json({ data: alert }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Alerte introuvable" }, { status: 404 }); + } + + await prisma.seasonAlert.delete({ where: { id } }); + return Response.json({ message: "Alerte supprimee" }); +} diff --git a/vineye-admin/app/api/alerts/route.ts b/vineye-admin/app/api/alerts/route.ts new file mode 100644 index 0000000..d2e3701 --- /dev/null +++ b/vineye-admin/app/api/alerts/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { alertSchema } from "@/lib/validations"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const region = searchParams.get("region"); + const active = searchParams.get("active"); + + const where: Record = {}; + if (region) where.region = region; + if (active !== null) where.active = active === "true"; + + const alerts = await prisma.seasonAlert.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return Response.json({ data: alerts }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = alertSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const alert = await prisma.seasonAlert.create({ + data: result.data, + }); + + return Response.json({ data: alert }, { status: 201 }); +} diff --git a/vineye-admin/app/api/auth/[...all]/route.ts b/vineye-admin/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5b67b06 --- /dev/null +++ b/vineye-admin/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/vineye-admin/app/api/diseases/[id]/route.ts b/vineye-admin/app/api/diseases/[id]/route.ts new file mode 100644 index 0000000..cd3346a --- /dev/null +++ b/vineye-admin/app/api/diseases/[id]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { diseaseSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const disease = await prisma.disease.findUnique({ where: { id } }); + if (!disease) { + return Response.json({ error: "Maladie introuvable" }, { status: 404 }); + } + + return Response.json({ data: disease }); +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.disease.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Maladie introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = diseaseSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.name); + + const slugConflict = await prisma.disease.findFirst({ + where: { slug, id: { not: id } }, + }); + if (slugConflict) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const disease = await prisma.disease.update({ + where: { id }, + data: { ...data, slug }, + }); + + return Response.json({ data: disease }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.disease.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Maladie introuvable" }, { status: 404 }); + } + + await prisma.disease.delete({ where: { id } }); + return Response.json({ message: "Maladie supprimee" }); +} diff --git a/vineye-admin/app/api/diseases/route.ts b/vineye-admin/app/api/diseases/route.ts new file mode 100644 index 0000000..bd4972b --- /dev/null +++ b/vineye-admin/app/api/diseases/route.ts @@ -0,0 +1,53 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { diseaseSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const type = searchParams.get("type"); + const published = searchParams.get("published"); + + const where: Record = {}; + if (type) where.type = type; + if (published !== null) where.published = published === "true"; + + const diseases = await prisma.disease.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return Response.json({ data: diseases }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = diseaseSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.name); + + const existing = await prisma.disease.findUnique({ where: { slug } }); + if (existing) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const disease = await prisma.disease.create({ + data: { ...data, slug }, + }); + + return Response.json({ data: disease }, { status: 201 }); +} diff --git a/vineye-admin/app/api/guides/[id]/route.ts b/vineye-admin/app/api/guides/[id]/route.ts new file mode 100644 index 0000000..c4bb618 --- /dev/null +++ b/vineye-admin/app/api/guides/[id]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { guideSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const guide = await prisma.guide.findUnique({ where: { id } }); + if (!guide) { + return Response.json({ error: "Guide introuvable" }, { status: 404 }); + } + + return Response.json({ data: guide }); +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.guide.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Guide introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = guideSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.title); + + const slugConflict = await prisma.guide.findFirst({ + where: { slug, id: { not: id } }, + }); + if (slugConflict) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const guide = await prisma.guide.update({ + where: { id }, + data: { ...data, slug }, + }); + + return Response.json({ data: guide }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.guide.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Guide introuvable" }, { status: 404 }); + } + + await prisma.guide.delete({ where: { id } }); + return Response.json({ message: "Guide supprime" }); +} diff --git a/vineye-admin/app/api/guides/route.ts b/vineye-admin/app/api/guides/route.ts new file mode 100644 index 0000000..2608f36 --- /dev/null +++ b/vineye-admin/app/api/guides/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { guideSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const published = searchParams.get("published"); + + const where: Record = {}; + if (published !== null) where.published = published === "true"; + + const guides = await prisma.guide.findMany({ + where, + orderBy: { order: "asc" }, + }); + + return Response.json({ data: guides }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = guideSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.title); + + const existing = await prisma.guide.findUnique({ where: { slug } }); + if (existing) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const guide = await prisma.guide.create({ + data: { ...data, slug }, + }); + + return Response.json({ data: guide }, { status: 201 }); +} diff --git a/vineye-admin/app/api/scans/[id]/route.ts b/vineye-admin/app/api/scans/[id]/route.ts new file mode 100644 index 0000000..c76e03c --- /dev/null +++ b/vineye-admin/app/api/scans/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + + const scan = await prisma.scan.findUnique({ + where: { id }, + include: { + user: { select: { name: true, email: true } }, + disease: { select: { name: true, severity: true } }, + }, + }); + + if (!scan) { + return Response.json({ error: "Scan introuvable" }, { status: 404 }); + } + + return Response.json({ data: scan }); +} diff --git a/vineye-admin/app/api/scans/route.ts b/vineye-admin/app/api/scans/route.ts new file mode 100644 index 0000000..59e8793 --- /dev/null +++ b/vineye-admin/app/api/scans/route.ts @@ -0,0 +1,90 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth, requireAdmin } from "@/lib/auth-guard"; +import { scanSchema } from "@/lib/validations"; + +export async function GET() { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const [totalScans, scansThisMonth, recentScans, diseaseStats] = await Promise.all([ + prisma.scan.count(), + prisma.scan.count({ + where: { + createdAt: { + gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + }, + }, + }), + prisma.scan.findMany({ + select: { + id: true, + confidence: true, + createdAt: true, + user: { select: { name: true } }, + disease: { select: { name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 10, + }), + prisma.scan.groupBy({ + by: ["diseaseId"], + _count: { id: true }, + where: { diseaseId: { not: null } }, + orderBy: { _count: { id: "desc" } }, + take: 5, + }), + ]); + + // Resolve disease names for stats + const diseaseIds = diseaseStats + .map((s) => s.diseaseId) + .filter((id): id is string => id !== null); + + const diseases = await prisma.disease.findMany({ + where: { id: { in: diseaseIds } }, + select: { id: true, name: true }, + }); + + const topDiseases = diseaseStats.map((stat) => ({ + name: diseases.find((d) => d.id === stat.diseaseId)?.name || "Inconnu", + count: stat._count.id, + })); + + return Response.json({ + data: { + totalScans, + scansThisMonth, + recentScans, + topDiseases, + }, + }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAuth(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = scanSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const scan = await prisma.scan.create({ + data: { + ...result.data, + userId: auth.session.user.id, + }, + }); + + return Response.json({ data: scan }, { status: 201 }); +} diff --git a/vineye-admin/app/api/users/[id]/route.ts b/vineye-admin/app/api/users/[id]/route.ts new file mode 100644 index 0000000..a01c850 --- /dev/null +++ b/vineye-admin/app/api/users/[id]/route.ts @@ -0,0 +1,96 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { z } from "zod/v4"; + +const patchUserSchema = z.object({ + role: z.enum(["USER", "ADMIN"]).optional(), + banned: z.boolean().optional(), + bannedReason: z.string().max(500).trim().optional().nullable(), +}); + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + bannedReason: true, + createdAt: true, + scans: { + select: { + id: true, + confidence: true, + createdAt: true, + disease: { select: { name: true, severity: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 20, + }, + _count: { select: { scans: true } }, + }, + }); + + if (!user) { + return Response.json({ error: "Utilisateur introuvable" }, { status: 404 }); + } + + return Response.json({ data: user }); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + + const existing = await prisma.user.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Utilisateur introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = patchUserSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const user = await prisma.user.update({ + where: { id }, + data: result.data, + select: { + id: true, + name: true, + email: true, + role: true, + banned: true, + bannedReason: true, + }, + }); + + return Response.json({ data: user }); +} diff --git a/vineye-admin/app/api/users/route.ts b/vineye-admin/app/api/users/route.ts new file mode 100644 index 0000000..90cb66e --- /dev/null +++ b/vineye-admin/app/api/users/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { searchParams } = request.nextUrl; + const role = searchParams.get("role"); + const search = searchParams.get("search"); + + const where: Record = {}; + if (role) where.role = role; + if (search) { + where.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ]; + } + + const users = await prisma.user.findMany({ + where, + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + createdAt: true, + _count: { select: { scans: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return Response.json({ data: users }); +} diff --git a/vineye-admin/app/favicon.ico b/vineye-admin/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/vineye-admin/app/globals.css b/vineye-admin/app/globals.css new file mode 100644 index 0000000..7c1ebef --- /dev/null +++ b/vineye-admin/app/globals.css @@ -0,0 +1,207 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-outfit); + --font-mono: var(--font-mono); + --font-display: var(--font-fraunces); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); + + /* VinEye "Cave a Vin" palette */ + --color-vine: #4ADE80; + --color-vine-dim: #166534; + --color-gold: #D97706; + --color-gold-dim: #92400E; + --color-wine: #BE123C; + --color-cream: #FAFAF9; + --color-stone-800: #292524; + --color-stone-700: #44403C; + --color-stone-600: #57534E; + --color-stone-400: #A8A29E; +} + +:root { + /* Warm stone dark palette — "Cave a Vin" */ + --background: oklch(0.11 0.005 60); + --foreground: oklch(0.93 0.005 80); + --card: oklch(0.15 0.005 60); + --card-foreground: oklch(0.93 0.005 80); + --popover: oklch(0.15 0.005 60); + --popover-foreground: oklch(0.93 0.005 80); + --primary: oklch(0.72 0.19 150); + --primary-foreground: oklch(0.13 0.01 150); + --secondary: oklch(0.19 0.005 60); + --secondary-foreground: oklch(0.85 0.005 80); + --muted: oklch(0.19 0.005 60); + --muted-foreground: oklch(0.58 0.01 75); + --accent: oklch(0.19 0.008 60); + --accent-foreground: oklch(0.93 0.005 80); + --destructive: oklch(0.55 0.22 25); + --border: oklch(0.23 0.006 60); + --input: oklch(0.20 0.005 60); + --ring: oklch(0.72 0.19 150); + --radius: 0.625rem; + + --chart-1: oklch(0.72 0.19 150); + --chart-2: oklch(0.68 0.14 85); + --chart-3: oklch(0.65 0.16 300); + --chart-4: oklch(0.70 0.13 230); + --chart-5: oklch(0.62 0.20 25); + + --sidebar: oklch(0.13 0.005 60); + --sidebar-foreground: oklch(0.78 0.005 80); + --sidebar-primary: oklch(0.72 0.19 150); + --sidebar-primary-foreground: oklch(0.13 0.01 150); + --sidebar-accent: oklch(0.18 0.015 150); + --sidebar-accent-foreground: oklch(0.78 0.12 150); + --sidebar-border: oklch(0.20 0.005 60); + --sidebar-ring: oklch(0.72 0.19 150); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +/* ─── Glow utilities ─── */ +.glow-green { + box-shadow: + 0 0 0 1px oklch(0.72 0.19 150 / 0.15), + 0 0 20px -5px oklch(0.72 0.19 150 / 0.15); +} +.glow-green-sm { + box-shadow: + 0 0 0 1px oklch(0.72 0.19 150 / 0.10), + 0 0 12px -4px oklch(0.72 0.19 150 / 0.12); +} +.glow-gold { + box-shadow: + 0 0 0 1px oklch(0.68 0.14 85 / 0.15), + 0 0 20px -5px oklch(0.68 0.14 85 / 0.15); +} +.glow-wine { + box-shadow: + 0 0 0 1px oklch(0.55 0.22 25 / 0.15), + 0 0 20px -5px oklch(0.55 0.22 25 / 0.15); +} + +/* ─── Glass card ─── */ +.glass-card { + background: oklch(0.15 0.005 60 / 0.6); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid oklch(0.28 0.006 60 / 0.5); +} + +/* ─── Gradient border ─── */ +.gradient-border { + position: relative; +} +.gradient-border::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient( + 135deg, + oklch(0.72 0.19 150 / 0.4), + oklch(0.68 0.14 85 / 0.2), + oklch(0.72 0.19 150 / 0.1) + ); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + pointer-events: none; +} + +/* ─── Vine pulse animation ─── */ +@keyframes vine-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} +.animate-vine-pulse { + animation: vine-pulse 3s ease-in-out infinite; +} + +/* ─── Subtle grain texture ─── */ +.grain::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + opacity: 0.03; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); +} + +/* ─── Scrollbar styling ─── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: oklch(0.30 0.005 60); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: oklch(0.38 0.005 60); +} + +/* ─── Selection color ─── */ +::selection { + background: oklch(0.72 0.19 150 / 0.3); + color: oklch(0.95 0.005 80); +} diff --git a/vineye-admin/app/layout.tsx b/vineye-admin/app/layout.tsx new file mode 100644 index 0000000..217f41e --- /dev/null +++ b/vineye-admin/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { Fraunces, Outfit } from "next/font/google"; +import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; + +const fraunces = Fraunces({ + variable: "--font-fraunces", + subsets: ["latin"], + display: "swap", +}); + +const outfit = Outfit({ + variable: "--font-outfit", + subsets: ["latin"], + display: "swap", +}); + +export const metadata: Metadata = { + title: "VinEye Admin", + description: "Panel d'administration VinEye — Gestion des maladies de la vigne", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/vineye-admin/app/page.tsx b/vineye-admin/app/page.tsx new file mode 100644 index 0000000..c3a1c90 --- /dev/null +++ b/vineye-admin/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function RootPage() { + redirect("/dashboard"); +} diff --git a/vineye-admin/components.json b/vineye-admin/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/vineye-admin/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/vineye-admin/components/admin/alert-form.tsx b/vineye-admin/components/admin/alert-form.tsx new file mode 100644 index 0000000..48b8c01 --- /dev/null +++ b/vineye-admin/components/admin/alert-form.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +interface AlertFormProps { + initialData?: { + id?: string; + title: string; + titleEn: string; + message: string; + messageEn: string; + type: string; + region: string; + active: boolean; + activeFrom: string; + activeTo: string; + }; + mode: "create" | "edit"; +} + +export default function AlertForm({ initialData, mode }: AlertFormProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + const [title, setTitle] = useState(initialData?.title ?? ""); + const [titleEn, setTitleEn] = useState(initialData?.titleEn ?? ""); + const [message, setMessage] = useState(initialData?.message ?? ""); + const [messageEn, setMessageEn] = useState(initialData?.messageEn ?? ""); + const [type, setType] = useState(initialData?.type ?? "WARNING"); + const [region, setRegion] = useState(initialData?.region ?? "bordeaux"); + const [active, setActive] = useState(initialData?.active ?? true); + const [activeFrom, setActiveFrom] = useState(initialData?.activeFrom ?? ""); + const [activeTo, setActiveTo] = useState(initialData?.activeTo ?? ""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (loading) return; + + if (!title.trim() || !message.trim()) { + toast.error("Veuillez remplir les champs obligatoires"); + return; + } + + setLoading(true); + + const body = { + title: title.trim(), + titleEn: titleEn.trim(), + message: message.trim(), + messageEn: messageEn.trim(), + type, + region: region.trim(), + active, + activeFrom: activeFrom || undefined, + activeTo: activeTo || null, + }; + + try { + const url = mode === "create" ? "/api/alerts" : `/api/alerts/${initialData?.id}`; + const method = mode === "create" ? "POST" : "PUT"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || "Erreur"); + return; + } + + toast.success(mode === "create" ? "Alerte creee" : "Alerte mise a jour"); + router.push("/alerts"); + router.refresh(); + } catch { + toast.error("Une erreur est survenue"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ +

+ {mode === "create" ? "Nouvelle alerte" : `Modifier — ${initialData?.title}`} +

+
+ +
+ + +

Informations

+
+
+ + setTitle(e.target.value)} className="rounded-xl" required /> +
+
+ + setTitleEn(e.target.value)} className="rounded-xl" /> +
+
+
+ +