Compare commits

..

No commits in common. "fe70005a86f7095d5e60f104bd6a3e22f50c2dac" and "af299e816abc0ddb96f934e2bdd6f80fb1c1645c" have entirely different histories.

101 changed files with 25 additions and 17443 deletions

View file

@ -1,10 +0,0 @@
# MEMORY — Projet
## Vue d'ensemble
<!-- Remplir au fil des sessions -->
## Décisions techniques importantes
<!-- Patterns, choix d'architecture, gotchas découverts -->
## État actuel
<!-- Ce qui est fait, ce qui reste à faire -->

View file

@ -1,29 +0,0 @@
# Index des fonctionnalités — ce projet
> Mis à jour automatiquement. Compléter les README.md de chaque feature après implémentation.
> **Règle** : Avant de travailler sur une feature → lire son README. Après → le mettre à jour.
## Fonctionnalités détectées
| Feature | Documentation | Status | Dernière MAJ |
|---------|--------------|--------|-------------|
## Fichiers critiques globaux
| Fichier | Rôle |
|---------|------|
| `CLAUDE.md` | Contexte projet chargé automatiquement |
| `.claude/notes/_features.md` | Cet index |
| `.claude/rules/` | Rules spécifiques au projet |
## Stack détectée
## Convention de mise à jour
Après chaque feature implémentée :
1. Ouvrir `.claude/notes/<feature>/README.md`
2. Compléter : description, fichiers clés, endpoints, gotchas
3. Mettre à jour le status dans cet index (🟡 → ✅)

11
.gitignore vendored
View file

@ -24,12 +24,5 @@ VinEye/dist/
VinEye/ios/ VinEye/ios/
VinEye/android/ VinEye/android/
# Virtual environment # dependances
venv/ node_modules/
# Dependencies
node_modules/
# vineye-admin
vineye-admin/node_modules/
vineye-admin/.next/

View file

@ -1,44 +0,0 @@
# Projet
> Généré par project-init. Compléter avec les spécificités du projet.
> **Limite : 200 lignes.** Aller à l'essentiel — les détails sont dans `.claude/notes/`.
---
## Stack
| Couche | Technologies |
|--------|-------------|
| Frontend | — |
| Backend | — |
| ORM | — |
| Auth | JWT (access + refresh tokens) |
---
## Architecture
```
docs/ venv/ VinEye/
```
---
## Fonctionnalités clés
> Voir `.claude/notes/_features.md` pour le détail de chaque feature.
---
## Conventions
- Package manager : **pnpm**
- Composants : shadcn/ui → Magic UI → custom (dans cet ordre)
- Server Components par défaut, `"use client"` uniquement si nécessaire
- Max 300 lignes par fichier
---
## À compléter
- [ ] Design tokens / palette couleurs
- [ ] Variables d'environnement nécessaires
- [ ] URLs de déploiement
- [ ] Spécificités métier importantes

View file

@ -38,10 +38,10 @@ export default function SearchHeader() {
<TouchableOpacity <TouchableOpacity
style={styles.notifButton} style={styles.notifButton}
activeOpacity={0.7} activeOpacity={0.7}
onPress={() => navigation.navigate("Settings")} onPress={() => navigation.navigate("Profile")}
> >
<Ionicons <Ionicons
name="settings-outline" name="person-outline"
size={22} size={22}
color={colors.neutral[800]} color={colors.neutral[800]}
/> />
@ -65,13 +65,13 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
brandTitle: { brandTitle: {
fontSize: 24, fontSize: 32,
fontWeight: "900", // Très gras pour l'identité fontWeight: "900", // Très gras pour l'identité
color: colors.primary[900], color: colors.primary[900],
letterSpacing: -1, // Look "Logo" letterSpacing: -1, // Look "Logo"
}, },
greetingText: { greetingText: {
fontSize: 14, fontSize: 10,
fontWeight: "500", fontWeight: "500",
color: colors.neutral[500], color: colors.neutral[500],
marginTop: -2, marginTop: -2,
@ -81,16 +81,27 @@ const styles = StyleSheet.create({
backgroundColor: "#FFFFFF", backgroundColor: "#FFFFFF",
borderWidth: 1, borderWidth: 1,
borderColor: "#F0F0F0", borderColor: "#F0F0F0",
borderRadius: 32, borderRadius: 16,
}, },
notifButton: { notifButton: {
height: 48, height: 48,
width: 48, width: 48,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
borderRadius: 32, borderRadius: 16,
backgroundColor: "#FFFFFF",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
},
android: {
elevation: 3,
},
}),
}, },
notifBadge: { notifBadge: {
position: "absolute", position: "absolute",

View file

@ -59,7 +59,6 @@ const styles = StyleSheet.create({
input: { input: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 15,
fontWeight: "500", fontWeight: "500",
color: colors.neutral[900], color: colors.neutral[900],
// Évite le décalage de texte sur Android // Évite le décalage de texte sur Android

View file

@ -50,7 +50,7 @@ const styles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
marginBottom: 16, // Espace constant sous le header
paddingHorizontal: 4, paddingHorizontal: 4,
}, },
title: { title: {

View file

@ -82,7 +82,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
backgroundColor: colors.primary[800], backgroundColor: colors.primary[800],
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
marginTop: -25, marginTop: -28,
shadowColor: colors.primary[900], shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
@ -122,7 +122,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
strokeWidth={isFocused ? 2.5 : 1.8} strokeWidth={isFocused ? 2.5 : 1.8}
/> />
)} )}
<Text {/* <Text
numberOfLines={1} numberOfLines={1}
style={{ style={{
fontSize: 11, fontSize: 11,
@ -132,7 +132,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
}} }}
> >
{label} {label}
</Text> </Text> */}
</TouchableOpacity> </TouchableOpacity>
); );
})} })}

View file

@ -1,6 +0,0 @@
{
"dependencies": {
"lucide-react": "^1.7.0",
"lucide-react-native": "^1.7.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,43 +0,0 @@
# 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

View file

@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

View file

@ -1 +0,0 @@
@AGENTS.md

View file

@ -1,36 +0,0 @@
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.

View file

@ -1,32 +0,0 @@
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 (
<AlertForm
mode="edit"
initialData={{
id: alert.id,
title: alert.title,
titleEn: alert.titleEn,
message: alert.message,
messageEn: alert.messageEn,
type: alert.type,
region: alert.region,
active: alert.active,
activeFrom: alert.activeFrom.toISOString().split("T")[0],
activeTo: alert.activeTo ? alert.activeTo.toISOString().split("T")[0] : "",
}}
/>
);
}

View file

@ -1,193 +0,0 @@
"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<string, string> = {
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 (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
Alertes saisonnieres
</h1>
<p className="text-sm text-stone-600 mt-1">{alerts.length} alertes</p>
</div>
<Link
href="/alerts/new"
className={cn(
buttonVariants(),
"rounded-xl bg-vine hover:bg-vine/90 text-[oklch(0.10_0.02_150)] font-semibold"
)}
>
<Plus className="h-4 w-4 mr-2" />
Ajouter
</Link>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-600" />
<Input
placeholder="Rechercher une alerte..."
value={search}
onChange={(e) => 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"
/>
</div>
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden hidden md:block">
<Table>
<TableHeader>
<TableRow className="border-[oklch(0.20_0.006_60)] hover:bg-transparent">
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Titre</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Type</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Region</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Periode</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Active</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((alert) => (
<TableRow key={alert.id} className="border-[oklch(0.20_0.006_60)] hover:bg-[oklch(0.16_0.005_60)] transition-colors">
<TableCell className="text-[13px] font-medium text-cream">{alert.title}</TableCell>
<TableCell>
<Badge variant="secondary" className={`text-[11px] font-medium border ${TYPE_STYLES[alert.type]}`}>
{alert.type}
</Badge>
</TableCell>
<TableCell className="text-[13px] text-stone-400 capitalize">{alert.region}</TableCell>
<TableCell className="text-[12px] font-mono text-stone-600">
{formatDateShort(alert.activeFrom)}
{alert.activeTo && `${formatDateShort(alert.activeTo)}`}
</TableCell>
<TableCell>
<Switch
checked={alert.active}
onCheckedChange={(checked) => handleToggleActive(alert.id, checked)}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Link
href={`/alerts/${alert.id}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-8 w-8 text-stone-600 hover:text-cream")}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
<DeleteDialog
title="Supprimer cette alerte ?"
description={`L'alerte "${alert.title}" sera supprimee.`}
onConfirm={() => handleDelete(alert.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length === 0 && (
<p className="text-sm text-stone-600 text-center py-10">Aucune alerte trouvee</p>
)}
</div>
{/* Mobile */}
<div className="md:hidden space-y-3">
{filtered.map((alert) => (
<div key={alert.id} className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-4 hover:border-[oklch(0.28_0.006_60)] transition-colors">
<div className="flex items-start justify-between mb-3">
<p className="text-[13px] font-medium text-cream">{alert.title}</p>
<Link
href={`/alerts/${alert.id}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7 shrink-0 text-stone-600")}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className={`text-[11px] font-medium border ${TYPE_STYLES[alert.type]}`}>
{alert.type}
</Badge>
<span className="text-[11px] text-stone-600 capitalize">{alert.region}</span>
<Switch
checked={alert.active}
onCheckedChange={(checked) => handleToggleActive(alert.id, checked)}
className="ml-auto"
/>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -1,5 +0,0 @@
import AlertForm from "@/components/admin/alert-form";
export default function NewAlertPage() {
return <AlertForm mode="create" />;
}

View file

@ -1,12 +0,0 @@
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 <AlertsClient alerts={alerts} />;
}

View file

@ -1,142 +0,0 @@
"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 (
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<div>
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
Tableau de bord
</h1>
<p className="text-sm text-stone-600 mt-1 capitalize">{today}</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Utilisateurs"
value={stats.totalUsers}
icon={Users}
accentClass="text-vine bg-vine/10"
/>
<StatCard
title="Scans ce mois"
value={stats.scansThisMonth}
icon={ScanLine}
accentClass="text-[#60A5FA] bg-[#60A5FA]/10"
/>
<StatCard
title="Maladies"
value={stats.totalDiseases}
icon={Bug}
accentClass="text-gold bg-gold/10"
/>
<StatCard
title="Alertes actives"
value={stats.activeAlerts}
icon={AlertTriangle}
accentClass="text-wine bg-wine/10"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Recent scans */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden">
<div className="px-5 py-4 border-b border-[oklch(0.20_0.006_60)]">
<h2 className="text-sm font-semibold text-cream">Scans recents</h2>
</div>
<div className="divide-y divide-[oklch(0.20_0.006_60)]">
{recentScans.length === 0 ? (
<p className="text-sm text-stone-600 py-8 text-center">
Aucun scan pour le moment
</p>
) : (
recentScans.map((scan) => (
<div
key={scan.id}
className="flex items-center justify-between px-5 py-3 hover:bg-[oklch(0.16_0.005_60)] transition-colors"
>
<div className="min-w-0">
<p className="text-[13px] font-medium text-cream truncate">{scan.diseaseName}</p>
<p className="text-[11px] text-stone-600">{scan.userName}</p>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<span className="text-[12px] font-mono font-medium text-vine">
{scan.confidence}%
</span>
<span className="text-[11px] text-stone-700">{scan.date}</span>
</div>
</div>
))
)}
</div>
</div>
{/* Top diseases */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden">
<div className="px-5 py-4 border-b border-[oklch(0.20_0.006_60)]">
<h2 className="text-sm font-semibold text-cream">Maladies les plus detectees</h2>
</div>
<div className="p-5 space-y-5">
{topDiseases.length === 0 ? (
<p className="text-sm text-stone-600 py-4 text-center">
Pas assez de donnees
</p>
) : (
topDiseases.map((disease, i) => (
<div key={disease.name} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="text-[11px] font-mono text-stone-700 w-4">
{String(i + 1).padStart(2, "0")}
</span>
<span className="text-[13px] font-medium text-cream">{disease.name}</span>
</div>
<Badge variant="secondary" className="text-[11px] font-mono bg-vine/8 text-vine border-0 px-2">
{disease.count}
</Badge>
</div>
<div className="ml-6.5 h-1.5 bg-[oklch(0.18_0.005_60)] rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700 ease-out"
style={{
width: `${(disease.count / maxCount) * 100}%`,
background: `linear-gradient(90deg, oklch(0.72 0.19 150 / 0.7), oklch(0.72 0.19 150))`,
}}
/>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,74 +0,0 @@
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 (
<DashboardClient
stats={{
totalUsers,
scansThisMonth,
totalDiseases,
activeAlerts,
}}
recentScans={formattedScans}
topDiseases={topDiseases}
/>
);
}

View file

@ -1,41 +0,0 @@
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 (
<DiseaseForm
mode="edit"
initialData={{
id: disease.id,
name: disease.name,
nameEn: disease.nameEn,
scientificName: disease.scientificName,
slug: disease.slug,
type: disease.type,
severity: disease.severity,
description: disease.description,
descriptionEn: disease.descriptionEn,
symptoms: disease.symptoms,
symptomsEn: disease.symptomsEn,
treatment: disease.treatment,
treatmentEn: disease.treatmentEn,
season: disease.season,
seasonEn: disease.seasonEn,
iconName: disease.iconName,
iconColor: disease.iconColor,
bgColor: disease.bgColor,
published: disease.published,
}}
/>
);
}

View file

@ -1,267 +0,0 @@
"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<string, string> = {
FUNGAL: "Fongique",
BACTERIAL: "Bacterien",
PEST: "Ravageur",
ABIOTIC: "Carence",
};
const TYPE_STYLES: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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 (
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
Maladies de la vigne
</h1>
<p className="text-sm text-stone-600 mt-1">{diseases.length} maladies repertoriees</p>
</div>
<Link
href="/diseases/new"
className={cn(
buttonVariants(),
"rounded-xl bg-vine hover:bg-vine/90 text-[oklch(0.10_0.02_150)] font-semibold"
)}
>
<Plus className="h-4 w-4 mr-2" />
Ajouter
</Link>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-600" />
<Input
placeholder="Rechercher une maladie..."
value={search}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-1.5">
{["ALL", "FUNGAL", "BACTERIAL", "PEST", "ABIOTIC"].map((type) => (
<Button
key={type}
variant="ghost"
size="sm"
className={cn(
"rounded-lg text-xs font-medium transition-all",
typeFilter === type
? "bg-vine/10 text-vine border border-vine/20"
: "text-stone-600 border border-transparent hover:text-cream hover:bg-[oklch(0.18_0.005_60)]"
)}
onClick={() => setTypeFilter(type)}
>
{type === "ALL" ? "Tous" : TYPE_LABELS[type]}
</Button>
))}
</div>
</div>
{/* Table (desktop) */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden hidden md:block">
<Table>
<TableHeader>
<TableRow className="border-[oklch(0.20_0.006_60)] hover:bg-transparent">
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Nom</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Type</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Severite</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Scans</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Publie</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((disease) => (
<TableRow key={disease.id} className="border-[oklch(0.20_0.006_60)] hover:bg-[oklch(0.16_0.005_60)] transition-colors">
<TableCell>
<div>
<p className="text-[13px] font-medium text-cream">{disease.name}</p>
{disease.scientificName && (
<p className="text-[11px] text-stone-600 italic">{disease.scientificName}</p>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className={`text-[11px] font-medium border ${TYPE_STYLES[disease.type]}`}>
{TYPE_LABELS[disease.type]}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className={`text-[11px] font-medium border ${SEVERITY_STYLES[disease.severity]}`}>
{SEVERITY_LABELS[disease.severity]}
</Badge>
</TableCell>
<TableCell>
<span className="text-[13px] font-mono text-stone-400">{disease._count.scans}</span>
</TableCell>
<TableCell>
<Switch
checked={disease.published}
onCheckedChange={(checked) => handleTogglePublish(disease.id, checked)}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Link
href={`/diseases/${disease.id}/edit`}
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"h-8 w-8 text-stone-600 hover:text-cream"
)}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
<DeleteDialog
title="Supprimer cette maladie ?"
description={`La maladie "${disease.name}" sera supprimee definitivement.`}
onConfirm={() => handleDelete(disease.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length === 0 && (
<p className="text-sm text-stone-600 text-center py-10">
Aucune maladie trouvee
</p>
)}
</div>
{/* Cards (mobile) */}
<div className="md:hidden space-y-3">
{filtered.map((disease) => (
<div key={disease.id} className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-4 hover:border-[oklch(0.28_0.006_60)] transition-colors">
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-[13px] font-medium text-cream">{disease.name}</p>
{disease.scientificName && (
<p className="text-[11px] text-stone-600 italic">{disease.scientificName}</p>
)}
</div>
<Link
href={`/diseases/${disease.id}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7 text-stone-600")}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className={`text-[11px] font-medium border ${TYPE_STYLES[disease.type]}`}>
{TYPE_LABELS[disease.type]}
</Badge>
<Badge variant="secondary" className={`text-[11px] font-medium border ${SEVERITY_STYLES[disease.severity]}`}>
{SEVERITY_LABELS[disease.severity]}
</Badge>
<span className="ml-auto text-[11px] font-mono text-stone-600">{disease._count.scans} scans</span>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -1,5 +0,0 @@
import DiseaseForm from "@/components/admin/disease-form";
export default function NewDiseasePage() {
return <DiseaseForm mode="create" />;
}

View file

@ -1,23 +0,0 @@
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 <DiseasesClient diseases={diseases} />;
}

View file

@ -1,35 +0,0 @@
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 (
<GuideForm
mode="edit"
initialData={{
id: guide.id,
title: guide.title,
titleEn: guide.titleEn,
subtitle: guide.subtitle,
subtitleEn: guide.subtitleEn,
content: guide.content,
contentEn: guide.contentEn,
category: guide.category,
iconName: guide.iconName,
iconColor: guide.iconColor,
bgColor: guide.bgColor,
published: guide.published,
order: guide.order,
}}
/>
);
}

View file

@ -1,180 +0,0 @@
"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<string, string> = {
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 (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
Guides
</h1>
<p className="text-sm text-stone-600 mt-1">{guides.length} guides</p>
</div>
<Link
href="/guides/new"
className={cn(
buttonVariants(),
"rounded-xl bg-vine hover:bg-vine/90 text-[oklch(0.10_0.02_150)] font-semibold"
)}
>
<Plus className="h-4 w-4 mr-2" />
Ajouter
</Link>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-600" />
<Input
placeholder="Rechercher un guide..."
value={search}
onChange={(e) => 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"
/>
</div>
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden hidden md:block">
<Table>
<TableHeader>
<TableRow className="border-[oklch(0.20_0.006_60)] hover:bg-transparent">
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Titre</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Categorie</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Ordre</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Publie</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((guide) => (
<TableRow key={guide.id} className="border-[oklch(0.20_0.006_60)] hover:bg-[oklch(0.16_0.005_60)] transition-colors">
<TableCell>
<div>
<p className="text-[13px] font-medium text-cream">{guide.title}</p>
<p className="text-[11px] text-stone-600 truncate max-w-xs">{guide.subtitle}</p>
</div>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={`text-[11px] font-medium border ${CATEGORY_STYLES[guide.category] || CATEGORY_STYLES.general}`}
>
{guide.category}
</Badge>
</TableCell>
<TableCell>
<span className="text-[13px] font-mono text-stone-400">{guide.order}</span>
</TableCell>
<TableCell>
<Switch checked={guide.published} disabled />
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Link
href={`/guides/${guide.id}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-8 w-8 text-stone-600 hover:text-cream")}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
<DeleteDialog
title="Supprimer ce guide ?"
description={`Le guide "${guide.title}" sera supprime definitivement.`}
onConfirm={() => handleDelete(guide.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length === 0 && (
<p className="text-sm text-stone-600 text-center py-10">Aucun guide trouve</p>
)}
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-3">
{filtered.map((guide) => (
<div key={guide.id} className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-4 hover:border-[oklch(0.28_0.006_60)] transition-colors">
<div className="flex items-start justify-between">
<div className="min-w-0">
<p className="text-[13px] font-medium text-cream">{guide.title}</p>
<p className="text-[11px] text-stone-600 truncate">{guide.subtitle}</p>
</div>
<Link
href={`/guides/${guide.id}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7 shrink-0 text-stone-600")}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex items-center gap-2 mt-3">
<Badge
variant="secondary"
className={`text-[11px] font-medium border ${CATEGORY_STYLES[guide.category] || CATEGORY_STYLES.general}`}
>
{guide.category}
</Badge>
<span className="text-[11px] font-mono text-stone-700">#{guide.order}</span>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -1,5 +0,0 @@
import GuideForm from "@/components/admin/guide-form";
export default function NewGuidePage() {
return <GuideForm mode="create" />;
}

View file

@ -1,12 +0,0 @@
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 <GuidesClient guides={guides} />;
}

View file

@ -1,45 +0,0 @@
"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 (
<div className="flex h-screen overflow-hidden bg-background">
{/* Desktop sidebar */}
<div className="hidden lg:flex">
<Sidebar
collapsed={collapsed}
onCollapse={() => setCollapsed(!collapsed)}
userName={userName}
userEmail={userEmail}
/>
</div>
{/* Mobile sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="p-0 w-[260px] border-r border-[oklch(0.20_0.006_60)] bg-[oklch(0.11_0.005_60)]">
<Sidebar userName={userName} userEmail={userEmail} />
</SheetContent>
</Sheet>
{/* Main content */}
<div className="flex flex-col flex-1 overflow-hidden">
<Header onMenuClick={() => setSidebarOpen(true)} userName={userName} />
<main className="flex-1 overflow-y-auto">
<div className="p-4 lg:p-8">{children}</div>
</main>
</div>
</div>
);
}

View file

@ -1,43 +0,0 @@
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 <UserDetailClient user={user} />;
}

View file

@ -1,207 +0,0 @@
"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<string, string> = {
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<string, unknown>) {
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 (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-stone-600 hover:text-cream"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="font-display text-2xl font-semibold tracking-tight text-cream">
Utilisateur
</h1>
</div>
{/* Profile card */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-6">
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-[oklch(0.25_0.006_60)]">
<AvatarFallback className="bg-vine/10 text-vine text-lg font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2.5 mb-1">
<h2 className="text-lg font-semibold text-cream">{user.name}</h2>
<Badge
variant="secondary"
className={`text-[11px] font-medium border ${
user.role === "ADMIN"
? "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20"
: "bg-[oklch(0.18_0.005_60)] text-stone-400 border-[oklch(0.25_0.006_60)]"
}`}
>
{user.role}
</Badge>
{user.banned && (
<Badge variant="secondary" className="text-[11px] font-medium bg-wine/10 text-[#FB7185] border border-wine/20">
Banni
</Badge>
)}
</div>
<p className="text-sm text-stone-400">{user.email}</p>
<p className="text-[11px] text-stone-600 mt-1">
Inscrit le {formatDate(user.createdAt)}
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-6">
{STAT_ITEMS.map((stat) => (
<div
key={stat.label}
className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-[oklch(0.12_0.005_60)] p-3.5 text-center"
>
<stat.icon className={`h-4 w-4 mx-auto mb-1.5 ${stat.color}`} strokeWidth={1.5} />
<p className="text-sm font-semibold text-cream font-mono">{stat.value}</p>
<p className="text-[10px] text-stone-600 uppercase tracking-wider mt-0.5">{stat.label}</p>
</div>
))}
</div>
</div>
{/* Admin actions */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-6 space-y-5">
<p className="text-[11px] font-semibold text-gold/70 uppercase tracking-[0.1em]">
Actions admin
</p>
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-cream">Role</p>
<p className="text-[11px] text-stone-600">Modifier le role de l&apos;utilisateur</p>
</div>
<Select value={user.role} onValueChange={(role) => handleUpdate({ role })}>
<SelectTrigger className="w-32 rounded-xl bg-[oklch(0.12_0.005_60)] border-[oklch(0.22_0.005_60)] text-cream">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USER">USER</SelectItem>
<SelectItem value="ADMIN">ADMIN</SelectItem>
</SelectContent>
</Select>
</div>
<div className="h-px bg-[oklch(0.20_0.006_60)]" />
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-cream">Bannir</p>
<p className="text-[11px] text-stone-600">Empecher l&apos;acces a l&apos;application</p>
</div>
<Switch
checked={user.banned}
onCheckedChange={(banned) => handleUpdate({ banned })}
/>
</div>
</div>
{/* Scan history */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden">
<div className="px-6 py-4 border-b border-[oklch(0.20_0.006_60)]">
<h3 className="text-sm font-semibold text-cream">Historique des scans</h3>
</div>
<div className="divide-y divide-[oklch(0.20_0.006_60)]">
{user.scans.length === 0 ? (
<p className="text-sm text-stone-600 text-center py-8">Aucun scan</p>
) : (
user.scans.map((scan) => (
<div
key={scan.id}
className="flex items-center justify-between px-6 py-3 hover:bg-[oklch(0.16_0.005_60)] transition-colors"
>
<div>
<p className="text-[13px] font-medium text-cream">{scan.disease?.name || "Non identifie"}</p>
<p className="text-[11px] text-stone-600">{formatDate(scan.createdAt)}</p>
</div>
<div className="flex items-center gap-2.5">
{scan.disease?.severity && (
<Badge
variant="secondary"
className={`text-[11px] font-medium border ${SEVERITY_STYLES[scan.disease.severity]}`}
>
{scan.disease.severity}
</Badge>
)}
<span className="text-[12px] font-mono font-medium text-vine">
{Math.round(scan.confidence * 100)}%
</span>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}

View file

@ -1,23 +0,0 @@
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 <UsersClient users={users} />;
}

View file

@ -1,182 +0,0 @@
"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 (
<div className="max-w-7xl mx-auto space-y-6">
<div>
<h1 className="font-display text-3xl font-semibold tracking-tight text-cream">
Utilisateurs
</h1>
<p className="text-sm text-stone-600 mt-1">{users.length} utilisateurs</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-600" />
<Input
placeholder="Rechercher par nom ou email..."
value={search}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-1.5">
{["ALL", "USER", "ADMIN"].map((role) => (
<Button
key={role}
variant="ghost"
size="sm"
className={cn(
"rounded-lg text-xs font-medium transition-all",
roleFilter === role
? "bg-vine/10 text-vine border border-vine/20"
: "text-stone-600 border border-transparent hover:text-cream hover:bg-[oklch(0.18_0.005_60)]"
)}
onClick={() => setRoleFilter(role)}
>
{role === "ALL" ? "Tous" : role}
</Button>
))}
</div>
</div>
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden hidden md:block">
<Table>
<TableHeader>
<TableRow className="border-[oklch(0.20_0.006_60)] hover:bg-transparent">
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Utilisateur</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Role</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">XP</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Niveau</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Scans</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Inscrit</TableHead>
<TableHead className="text-[11px] font-semibold text-stone-600 uppercase tracking-wider">Statut</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((user) => (
<TableRow key={user.id} className="border-[oklch(0.20_0.006_60)] hover:bg-[oklch(0.16_0.005_60)] transition-colors">
<TableCell>
<Link href={`/users/${user.id}`} className="flex items-center gap-3 group">
<Avatar className="h-8 w-8 ring-1 ring-[oklch(0.25_0.006_60)] group-hover:ring-vine/30 transition-all">
<AvatarFallback className="bg-vine/10 text-vine text-[11px] font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-[13px] font-medium text-cream group-hover:text-vine transition-colors">{user.name}</p>
<p className="text-[11px] text-stone-600">{user.email}</p>
</div>
</Link>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={`text-[11px] font-medium border ${
user.role === "ADMIN"
? "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20"
: "bg-[oklch(0.18_0.005_60)] text-stone-400 border-[oklch(0.25_0.006_60)]"
}`}
>
{user.role}
</Badge>
</TableCell>
<TableCell className="text-[13px] font-mono text-stone-400">{user.xp}</TableCell>
<TableCell className="text-[13px] font-mono text-stone-400">{user.level}</TableCell>
<TableCell className="text-[13px] font-mono text-stone-400">{user._count.scans}</TableCell>
<TableCell className="text-[12px] font-mono text-stone-600">{formatDateShort(user.createdAt)}</TableCell>
<TableCell>
{user.banned ? (
<Badge variant="secondary" className="text-[11px] font-medium bg-wine/10 text-[#FB7185] border border-wine/20">
Banni
</Badge>
) : (
<span className="inline-flex items-center gap-1.5 text-[11px] text-vine">
<span className="h-1.5 w-1.5 rounded-full bg-vine animate-vine-pulse" />
Actif
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length === 0 && (
<p className="text-sm text-stone-600 text-center py-10">Aucun utilisateur trouve</p>
)}
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-3">
{filtered.map((user) => (
<Link key={user.id} href={`/users/${user.id}`}>
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-4 mb-3 hover:border-[oklch(0.28_0.006_60)] transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 ring-1 ring-[oklch(0.25_0.006_60)]">
<AvatarFallback className="bg-vine/10 text-vine text-sm font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-cream truncate">{user.name}</p>
<p className="text-[11px] text-stone-600 truncate">{user.email}</p>
</div>
<Badge
variant="secondary"
className={`text-[11px] font-medium shrink-0 border ${
user.role === "ADMIN"
? "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20"
: "bg-[oklch(0.18_0.005_60)] text-stone-400 border-[oklch(0.25_0.006_60)]"
}`}
>
{user.role}
</Badge>
</div>
</div>
</Link>
))}
</div>
</div>
);
}

View file

@ -1,10 +0,0 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative min-h-screen flex items-center justify-center bg-background overflow-hidden">
{/* Ambient glow */}
<div className="absolute top-[-30%] left-[-10%] w-[500px] h-[500px] rounded-full bg-vine/5 blur-[120px] pointer-events-none" />
<div className="absolute bottom-[-20%] right-[-5%] w-[400px] h-[400px] rounded-full bg-gold/5 blur-[100px] pointer-events-none" />
{children}
</div>
);
}

View file

@ -1,138 +0,0 @@
"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 (
<div className="w-full max-w-[380px] mx-4">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<div className="h-14 w-14 rounded-2xl bg-vine/10 flex items-center justify-center mb-5 glow-green-sm">
<Grape className="h-7 w-7 text-vine" />
</div>
<h1 className="font-display text-2xl font-semibold tracking-tight text-cream">
VinEye Admin
</h1>
<p className="text-sm text-stone-600 mt-1">
Connectez-vous pour acceder au panel
</p>
</div>
{/* Form card */}
<div className="glass-card rounded-2xl p-7">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label
htmlFor="email"
className="text-[11px] font-semibold text-stone-400 uppercase tracking-[0.08em]"
>
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => 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
/>
</div>
<div className="space-y-2">
<Label
htmlFor="password"
className="text-[11px] font-semibold text-stone-400 uppercase tracking-[0.08em]"
>
Mot de passe
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => 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
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-600 hover:text-stone-400 transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button
type="submit"
className="w-full h-11 rounded-xl bg-vine hover:bg-vine/90 text-[oklch(0.10_0.02_150)] font-semibold transition-all duration-200 hover:shadow-[0_0_20px_-5px_oklch(0.72_0.19_150_/_0.4)]"
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Se connecter"
)}
</Button>
</form>
</div>
{/* Footer */}
<p className="text-center text-[11px] text-stone-700 mt-6">
VinEye Detection des maladies de la vigne
</p>
</div>
);
}

View file

@ -1,70 +0,0 @@
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" });
}

View file

@ -1,44 +0,0 @@
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<string, unknown> = {};
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 });
}

View file

@ -1,4 +0,0 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View file

@ -1,81 +0,0 @@
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" });
}

View file

@ -1,53 +0,0 @@
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<string, unknown> = {};
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 });
}

View file

@ -1,81 +0,0 @@
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" });
}

View file

@ -1,51 +0,0 @@
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<string, unknown> = {};
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 });
}

View file

@ -1,29 +0,0 @@
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 });
}

View file

@ -1,90 +0,0 @@
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 });
}

View file

@ -1,96 +0,0 @@
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 });
}

View file

@ -1,41 +0,0 @@
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<string, unknown> = {};
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 });
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,207 +0,0 @@
@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);
}

View file

@ -1,49 +0,0 @@
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 (
<html
lang="fr"
className={`${fraunces.variable} ${outfit.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
{children}
<Toaster
position="top-right"
richColors
toastOptions={{
style: {
background: "oklch(0.17 0.005 60)",
border: "1px solid oklch(0.25 0.006 60)",
color: "oklch(0.93 0.005 80)",
},
}}
/>
</body>
</html>
);
}

View file

@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

View file

@ -1,25 +0,0 @@
{
"$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": {}
}

View file

@ -1,191 +0,0 @@
"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 (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-semibold tracking-tight">
{mode === "create" ? "Nouvelle alerte" : `Modifier — ${initialData?.title}`}
</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Informations</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Titre (FR) *</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Titre (EN)</Label>
<Input value={titleEn} onChange={(e) => setTitleEn(e.target.value)} className="rounded-xl" />
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Message (FR) *</Label>
<Textarea value={message} onChange={(e) => setMessage(e.target.value)} rows={3} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Message (EN)</Label>
<Textarea value={messageEn} onChange={(e) => setMessageEn(e.target.value)} rows={3} className="rounded-xl" />
</div>
</CardContent>
</Card>
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Configuration</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Type</Label>
<Select value={type} onValueChange={(v) => v && setType(v)}>
<SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="WARNING">Warning</SelectItem>
<SelectItem value="INFO">Info</SelectItem>
<SelectItem value="DANGER">Danger</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Region</Label>
<Input value={region} onChange={(e) => setRegion(e.target.value)} className="rounded-xl" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Date debut</Label>
<Input type="date" value={activeFrom} onChange={(e) => setActiveFrom(e.target.value)} className="rounded-xl" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Date fin</Label>
<Input type="date" value={activeTo} onChange={(e) => setActiveTo(e.target.value)} className="rounded-xl" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Active</p>
<p className="text-xs text-muted-foreground">Visible dans l&apos;app mobile</p>
</div>
<Switch checked={active} onCheckedChange={setActive} />
</div>
</CardContent>
</Card>
<div className="flex gap-3 justify-end">
<Button type="button" variant="ghost" onClick={() => router.back()} className="rounded-xl">Annuler</Button>
<Button type="submit" disabled={loading} className="rounded-xl">
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{mode === "create" ? "Creer" : "Enregistrer"}
</Button>
</div>
</form>
</div>
);
}

View file

@ -1,72 +0,0 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Trash2, Loader2 } from "lucide-react";
import { useState } from "react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface DeleteDialogProps {
title: string;
description: string;
onConfirm: () => Promise<void>;
}
export default function DeleteDialog({ title, description, onConfirm }: DeleteDialogProps) {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
async function handleConfirm() {
setLoading(true);
try {
await onConfirm();
setOpen(false);
} finally {
setLoading(false);
}
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"h-8 w-8 text-stone-600 hover:text-[#FB7185] hover:bg-wine/10"
)}
>
<Trash2 className="h-3.5 w-3.5" />
</AlertDialogTrigger>
<AlertDialogContent className="border-[oklch(0.22_0.006_60)] bg-card">
<AlertDialogHeader>
<AlertDialogTitle className="font-display font-semibold text-cream">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-stone-400">{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={loading}
className="rounded-xl bg-[oklch(0.18_0.005_60)] border-[oklch(0.25_0.006_60)] text-cream hover:bg-[oklch(0.22_0.005_60)]"
>
Annuler
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={loading}
className="rounded-xl bg-wine hover:bg-wine/90 text-white"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Supprimer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -1,360 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Plus, X, 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 { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { slugify } from "@/lib/utils";
import type { DiseaseInput } from "@/lib/validations";
interface DiseaseFormProps {
initialData?: DiseaseInput & { id?: string; slug?: string };
mode: "create" | "edit";
}
export default function DiseaseForm({ initialData, mode }: DiseaseFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [name, setName] = useState(initialData?.name ?? "");
const [nameEn, setNameEn] = useState(initialData?.nameEn ?? "");
const [scientificName, setScientificName] = useState(initialData?.scientificName ?? "");
const [type, setType] = useState(initialData?.type ?? "FUNGAL");
const [severity, setSeverity] = useState(initialData?.severity ?? "MEDIUM");
const [description, setDescription] = useState(initialData?.description ?? "");
const [descriptionEn, setDescriptionEn] = useState(initialData?.descriptionEn ?? "");
const [symptoms, setSymptoms] = useState<string[]>(initialData?.symptoms ?? [""]);
const [symptomsEn, setSymptomsEn] = useState<string[]>(initialData?.symptomsEn ?? [""]);
const [treatment, setTreatment] = useState(initialData?.treatment ?? "");
const [treatmentEn, setTreatmentEn] = useState(initialData?.treatmentEn ?? "");
const [season, setSeason] = useState(initialData?.season ?? "");
const [seasonEn, setSeasonEn] = useState(initialData?.seasonEn ?? "");
const [iconName, setIconName] = useState(initialData?.iconName ?? "leaf");
const [iconColor, setIconColor] = useState(initialData?.iconColor ?? "#1D9E75");
const [bgColor, setBgColor] = useState(initialData?.bgColor ?? "#E1F5EE");
const [published, setPublished] = useState(initialData?.published ?? true);
function addSymptom(lang: "fr" | "en") {
if (lang === "fr") setSymptoms([...symptoms, ""]);
else setSymptomsEn([...symptomsEn, ""]);
}
function removeSymptom(lang: "fr" | "en", index: number) {
if (lang === "fr") setSymptoms(symptoms.filter((_, i) => i !== index));
else setSymptomsEn(symptomsEn.filter((_, i) => i !== index));
}
function updateSymptom(lang: "fr" | "en", index: number, value: string) {
if (lang === "fr") {
const next = [...symptoms];
next[index] = value;
setSymptoms(next);
} else {
const next = [...symptomsEn];
next[index] = value;
setSymptomsEn(next);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (loading) return;
if (!name.trim() || !description.trim() || !treatment.trim() || !season.trim()) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
const filteredSymptoms = symptoms.filter((s) => s.trim());
if (filteredSymptoms.length === 0) {
toast.error("Ajoutez au moins un symptome");
return;
}
setLoading(true);
const body: DiseaseInput = {
name: name.trim(),
nameEn: nameEn.trim(),
scientificName: scientificName.trim(),
slug: slugify(name),
type: type as DiseaseInput["type"],
severity: severity as DiseaseInput["severity"],
description: description.trim(),
descriptionEn: descriptionEn.trim(),
symptoms: filteredSymptoms,
symptomsEn: symptomsEn.filter((s) => s.trim()),
treatment: treatment.trim(),
treatmentEn: treatmentEn.trim(),
season: season.trim(),
seasonEn: seasonEn.trim(),
iconName,
iconColor,
bgColor,
published,
};
try {
const url =
mode === "create"
? "/api/diseases"
: `/api/diseases/${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" ? "Maladie creee" : "Maladie mise a jour");
router.push("/diseases");
router.refresh();
} catch {
toast.error("Une erreur est survenue");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-semibold tracking-tight">
{mode === "create" ? "Nouvelle maladie" : `Modifier — ${initialData?.name}`}
</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* General info */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Informations generales
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Nom (FR) *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Nom (EN)</Label>
<Input value={nameEn} onChange={(e) => setNameEn(e.target.value)} className="rounded-xl" />
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Nom scientifique</Label>
<Input value={scientificName} onChange={(e) => setScientificName(e.target.value)} className="rounded-xl" />
</div>
</CardContent>
</Card>
{/* Classification */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Classification
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Type *</Label>
<Select value={type} onValueChange={(v) => v && setType(v)}>
<SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="FUNGAL">Fongique</SelectItem>
<SelectItem value="BACTERIAL">Bacterien</SelectItem>
<SelectItem value="PEST">Ravageur</SelectItem>
<SelectItem value="ABIOTIC">Carence</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Severite *</Label>
<Select value={severity} onValueChange={(v) => v && setSeverity(v)}>
<SelectTrigger className="rounded-xl"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="HIGH">Critique</SelectItem>
<SelectItem value="MEDIUM">Modere</SelectItem>
<SelectItem value="LOW">Faible</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Description */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Description
</p>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Description (FR) *</Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Description (EN)</Label>
<Textarea value={descriptionEn} onChange={(e) => setDescriptionEn(e.target.value)} rows={3} className="rounded-xl" />
</div>
</CardContent>
</Card>
{/* Symptoms */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Symptomes
</p>
<div className="space-y-3">
<Label className="text-xs font-medium text-muted-foreground">Symptomes (FR) *</Label>
{symptoms.map((s, i) => (
<div key={i} className="flex gap-2">
<Input
value={s}
onChange={(e) => updateSymptom("fr", i, e.target.value)}
className="rounded-xl"
placeholder={`Symptome ${i + 1}`}
/>
{symptoms.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => removeSymptom("fr", i)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button type="button" variant="secondary" size="sm" className="rounded-lg" onClick={() => addSymptom("fr")}>
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium text-muted-foreground">Symptomes (EN)</Label>
{symptomsEn.map((s, i) => (
<div key={i} className="flex gap-2">
<Input
value={s}
onChange={(e) => updateSymptom("en", i, e.target.value)}
className="rounded-xl"
placeholder={`Symptom ${i + 1}`}
/>
{symptomsEn.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => removeSymptom("en", i)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button type="button" variant="secondary" size="sm" className="rounded-lg" onClick={() => addSymptom("en")}>
<Plus className="h-3.5 w-3.5 mr-1" /> Ajouter
</Button>
</div>
</CardContent>
</Card>
{/* Treatment + Season */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Traitement & Saison
</p>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Traitement (FR) *</Label>
<Textarea value={treatment} onChange={(e) => setTreatment(e.target.value)} rows={2} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Traitement (EN)</Label>
<Textarea value={treatmentEn} onChange={(e) => setTreatmentEn(e.target.value)} rows={2} className="rounded-xl" />
</div>
<Separator />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Saison (FR) *</Label>
<Input value={season} onChange={(e) => setSeason(e.target.value)} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Saison (EN)</Label>
<Input value={seasonEn} onChange={(e) => setSeasonEn(e.target.value)} className="rounded-xl" />
</div>
</div>
</CardContent>
</Card>
{/* Appearance */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Apparence
</p>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Icone</Label>
<Input value={iconName} onChange={(e) => setIconName(e.target.value)} className="rounded-xl" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Couleur icone</Label>
<div className="flex items-center gap-2">
<input type="color" value={iconColor} onChange={(e) => setIconColor(e.target.value)} className="h-9 w-9 rounded-lg border border-input cursor-pointer" />
<Input value={iconColor} onChange={(e) => setIconColor(e.target.value)} className="rounded-xl" />
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Couleur fond</Label>
<div className="flex items-center gap-2">
<input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="h-9 w-9 rounded-lg border border-input cursor-pointer" />
<Input value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="rounded-xl" />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Publish + Actions */}
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Publier</p>
<p className="text-xs text-muted-foreground">Visible dans l&apos;app mobile</p>
</div>
<Switch checked={published} onCheckedChange={setPublished} />
</div>
</CardContent>
</Card>
<div className="flex gap-3 justify-end">
<Button type="button" variant="ghost" onClick={() => router.back()} className="rounded-xl">
Annuler
</Button>
<Button type="submit" disabled={loading} className="rounded-xl">
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{mode === "create" ? "Creer" : "Enregistrer"}
</Button>
</div>
</form>
</div>
);
}

View file

@ -1,214 +0,0 @@
"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 { toast } from "sonner";
import { slugify } from "@/lib/utils";
interface GuideFormProps {
initialData?: {
id?: string;
title: string;
titleEn: string;
subtitle: string;
subtitleEn: string;
content: string;
contentEn: string;
category: string;
iconName: string;
iconColor: string;
bgColor: string;
published: boolean;
order: number;
};
mode: "create" | "edit";
}
export default function GuideForm({ initialData, mode }: GuideFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState(initialData?.title ?? "");
const [titleEn, setTitleEn] = useState(initialData?.titleEn ?? "");
const [subtitle, setSubtitle] = useState(initialData?.subtitle ?? "");
const [subtitleEn, setSubtitleEn] = useState(initialData?.subtitleEn ?? "");
const [content, setContent] = useState(initialData?.content ?? "");
const [contentEn, setContentEn] = useState(initialData?.contentEn ?? "");
const [category, setCategory] = useState(initialData?.category ?? "general");
const [iconName, setIconName] = useState(initialData?.iconName ?? "book");
const [iconColor, setIconColor] = useState(initialData?.iconColor ?? "#185FA5");
const [bgColor, setBgColor] = useState(initialData?.bgColor ?? "#E6F1FB");
const [published, setPublished] = useState(initialData?.published ?? true);
const [order, setOrder] = useState(initialData?.order ?? 0);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (loading) return;
if (!title.trim() || !subtitle.trim() || !content.trim()) {
toast.error("Veuillez remplir les champs obligatoires");
return;
}
setLoading(true);
const body = {
title: title.trim(),
titleEn: titleEn.trim(),
slug: slugify(title),
subtitle: subtitle.trim(),
subtitleEn: subtitleEn.trim(),
content: content.trim(),
contentEn: contentEn.trim(),
category,
iconName,
iconColor,
bgColor,
published,
order,
};
try {
const url = mode === "create" ? "/api/guides" : `/api/guides/${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" ? "Guide cree" : "Guide mis a jour");
router.push("/guides");
router.refresh();
} catch {
toast.error("Une erreur est survenue");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-semibold tracking-tight">
{mode === "create" ? "Nouveau guide" : `Modifier — ${initialData?.title}`}
</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Informations</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Titre (FR) *</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Titre (EN)</Label>
<Input value={titleEn} onChange={(e) => setTitleEn(e.target.value)} className="rounded-xl" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Sous-titre (FR) *</Label>
<Input value={subtitle} onChange={(e) => setSubtitle(e.target.value)} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Sous-titre (EN)</Label>
<Input value={subtitleEn} onChange={(e) => setSubtitleEn(e.target.value)} className="rounded-xl" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Categorie</Label>
<Input value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-xl" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Ordre</Label>
<Input type="number" value={order} onChange={(e) => setOrder(parseInt(e.target.value) || 0)} className="rounded-xl" min={0} />
</div>
</div>
</CardContent>
</Card>
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Contenu</p>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Contenu (FR) *</Label>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} className="rounded-xl" required />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Contenu (EN)</Label>
<Textarea value={contentEn} onChange={(e) => setContentEn(e.target.value)} rows={6} className="rounded-xl" />
</div>
</CardContent>
</Card>
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5 space-y-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Apparence</p>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Icone</Label>
<Input value={iconName} onChange={(e) => setIconName(e.target.value)} className="rounded-xl" />
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Couleur icone</Label>
<div className="flex items-center gap-2">
<input type="color" value={iconColor} onChange={(e) => setIconColor(e.target.value)} className="h-9 w-9 rounded-lg border border-input cursor-pointer" />
<Input value={iconColor} onChange={(e) => setIconColor(e.target.value)} className="rounded-xl" />
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Couleur fond</Label>
<div className="flex items-center gap-2">
<input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="h-9 w-9 rounded-lg border border-input cursor-pointer" />
<Input value={bgColor} onChange={(e) => setBgColor(e.target.value)} className="rounded-xl" />
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-[#F0F0F0] shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Publier</p>
<p className="text-xs text-muted-foreground">Visible dans l&apos;app mobile</p>
</div>
<Switch checked={published} onCheckedChange={setPublished} />
</div>
</CardContent>
</Card>
<div className="flex gap-3 justify-end">
<Button type="button" variant="ghost" onClick={() => router.back()} className="rounded-xl">Annuler</Button>
<Button type="submit" disabled={loading} className="rounded-xl">
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{mode === "create" ? "Creer" : "Enregistrer"}
</Button>
</div>
</form>
</div>
);
}

View file

@ -1,81 +0,0 @@
"use client";
import { Menu, Bell, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { signOut } from "@/lib/auth-client";
interface HeaderProps {
onMenuClick: () => void;
userName?: string;
}
export default function Header({ onMenuClick, userName = "Admin" }: HeaderProps) {
function handleSignOut() {
signOut({ fetchOptions: { onSuccess: () => { window.location.href = "/login"; } } });
}
return (
<header className="flex items-center justify-between h-14 px-4 lg:px-6 border-b border-[oklch(0.20_0.006_60)] bg-[oklch(0.11_0.005_60_/_0.8)] backdrop-blur-xl shrink-0">
<Button
variant="ghost"
size="icon"
className="lg:hidden h-9 w-9 text-stone-400 hover:text-cream"
onClick={onMenuClick}
>
<Menu className="h-5 w-5" />
</Button>
{/* Search hint (desktop) */}
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 rounded-lg border border-[oklch(0.22_0.005_60)] bg-[oklch(0.14_0.005_60)] text-stone-600 text-xs cursor-pointer hover:border-stone-700 transition-colors">
<Search className="h-3.5 w-3.5" />
<span>Rechercher...</span>
<kbd className="ml-4 px-1.5 py-0.5 rounded bg-[oklch(0.18_0.005_60)] border border-[oklch(0.25_0.006_60)] text-[10px] font-mono text-stone-600">
Ctrl K
</kbd>
</div>
<div className="flex-1 lg:flex-none" />
<div className="flex items-center gap-1.5">
{/* Notifications */}
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9 text-stone-600 hover:text-cream hover:bg-[oklch(0.17_0.005_60)]"
>
<Bell className="h-[18px] w-[18px]" strokeWidth={1.5} />
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-vine animate-vine-pulse" />
</Button>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger className="h-9 w-9 rounded-lg p-0 inline-flex items-center justify-center hover:bg-[oklch(0.17_0.005_60)] transition-colors">
<Avatar className="h-7 w-7 ring-1 ring-[oklch(0.25_0.006_60)]">
<AvatarFallback className="bg-vine/10 text-vine text-[11px] font-semibold">
{userName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem className="text-sm text-muted-foreground">{userName}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-sm text-destructive focus:text-destructive"
onClick={handleSignOut}
>
Se deconnecter
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View file

@ -1,193 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Bug,
BookOpen,
AlertTriangle,
Users,
LogOut,
Grape,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { signOut } from "@/lib/auth-client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
interface SidebarProps {
collapsed?: boolean;
onCollapse?: () => void;
userName?: string;
userEmail?: string;
}
const NAV_ITEMS = [
{
section: "Principal",
items: [
{ label: "Tableau de bord", href: "/dashboard", icon: LayoutDashboard },
],
},
{
section: "Contenu",
items: [
{ label: "Maladies", href: "/diseases", icon: Bug },
{ label: "Guides", href: "/guides", icon: BookOpen },
{ label: "Alertes", href: "/alerts", icon: AlertTriangle },
],
},
{
section: "Gestion",
items: [
{ label: "Utilisateurs", href: "/users", icon: Users },
],
},
];
export default function Sidebar({
collapsed = false,
onCollapse,
userName = "Admin",
userEmail = "admin@vineye.app",
}: SidebarProps) {
const pathname = usePathname();
function handleSignOut() {
signOut({ fetchOptions: { onSuccess: () => { window.location.href = "/login"; } } });
}
return (
<aside
className={cn(
"relative flex flex-col h-full bg-[oklch(0.11_0.005_60)] border-r border-[oklch(0.20_0.006_60)] transition-all duration-300",
collapsed ? "w-[68px]" : "w-[260px]"
)}
>
{/* Logo area */}
<div className="flex items-center justify-between px-4 h-16 shrink-0">
{!collapsed && (
<Link href="/dashboard" className="flex items-center gap-2.5 group">
<div className="h-8 w-8 rounded-lg bg-vine/10 flex items-center justify-center group-hover:bg-vine/15 transition-colors">
<Grape className="h-4.5 w-4.5 text-vine" />
</div>
<span className="font-display text-lg font-semibold tracking-tight text-cream">
VinEye
</span>
</Link>
)}
{collapsed && (
<Link href="/dashboard" className="mx-auto">
<div className="h-8 w-8 rounded-lg bg-vine/10 flex items-center justify-center hover:bg-vine/15 transition-colors">
<Grape className="h-4.5 w-4.5 text-vine" />
</div>
</Link>
)}
</div>
{/* Collapse toggle */}
{onCollapse && (
<button
onClick={onCollapse}
className="absolute -right-3 top-[52px] z-10 h-6 w-6 rounded-full border border-[oklch(0.25_0.006_60)] bg-[oklch(0.15_0.005_60)] flex items-center justify-center text-stone-400 hover:text-vine hover:border-vine/30 transition-colors"
>
{collapsed ? (
<ChevronRight className="h-3 w-3" />
) : (
<ChevronLeft className="h-3 w-3" />
)}
</button>
)}
{/* Divider */}
<div className="mx-3 h-px bg-gradient-to-r from-transparent via-[oklch(0.25_0.006_60)] to-transparent" />
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-5 px-3 space-y-6">
{NAV_ITEMS.map((group) => (
<div key={group.section}>
{!collapsed && (
<p className="px-3 mb-2.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-gold/70">
{group.section}
</p>
)}
<div className="space-y-0.5">
{group.items.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"group relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-[13px] font-medium transition-all duration-200",
isActive
? "bg-vine/8 text-vine"
: "text-stone-400 hover:text-cream hover:bg-[oklch(0.17_0.005_60)]",
collapsed && "justify-center px-0"
)}
>
{/* Active indicator bar */}
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-vine" />
)}
<Icon
className={cn(
"h-[18px] w-[18px] shrink-0 transition-colors",
isActive ? "text-vine" : "text-stone-600 group-hover:text-stone-400"
)}
strokeWidth={isActive ? 2 : 1.5}
/>
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</div>
</div>
))}
</nav>
{/* Divider */}
<div className="mx-3 h-px bg-gradient-to-r from-transparent via-[oklch(0.25_0.006_60)] to-transparent" />
{/* User */}
<div className={cn("p-3 shrink-0", collapsed && "flex justify-center")}>
{!collapsed ? (
<div className="flex items-center gap-3 px-1">
<Avatar className="h-8 w-8 ring-1 ring-[oklch(0.25_0.006_60)]">
<AvatarFallback className="bg-vine/10 text-vine text-xs font-semibold">
{userName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-cream truncate">{userName}</p>
<p className="text-[11px] text-stone-600 truncate">{userEmail}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-stone-600 hover:text-wine hover:bg-wine/10"
onClick={handleSignOut}
>
<LogOut className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-stone-600 hover:text-wine hover:bg-wine/10"
onClick={handleSignOut}
>
<LogOut className="h-3.5 w-3.5" />
</Button>
)}
</div>
</aside>
);
}

View file

@ -1,38 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
interface StatCardProps {
title: string;
value: number;
icon: LucideIcon;
accentClass?: string;
}
export default function StatCard({
title,
value,
icon: Icon,
accentClass = "text-vine bg-vine/10",
}: StatCardProps) {
return (
<div className="group relative rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-5 transition-all duration-300 hover:border-[oklch(0.28_0.006_60)] hover:bg-[oklch(0.16_0.005_60)]">
{/* Subtle gradient top accent */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-vine/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="flex items-center justify-between">
<div>
<p className="text-[11px] font-semibold text-stone-600 uppercase tracking-[0.08em] mb-2">
{title}
</p>
<p className="text-[28px] font-display font-semibold tracking-tight text-cream leading-none">
{value.toLocaleString("fr-FR")}
</p>
</div>
<div className={`h-11 w-11 rounded-xl flex items-center justify-center ${accentClass}`}>
<Icon className="h-5 w-5" strokeWidth={1.5} />
</div>
</div>
</div>
);
}

View file

@ -1,187 +0,0 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View file

@ -1,109 +0,0 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View file

@ -1,52 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View file

@ -1,60 +0,0 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -1,103 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -1,160 +0,0 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -1,268 +0,0 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -1,20 +0,0 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -1,20 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -1,201 +0,0 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -1,25 +0,0 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -1,138 +0,0 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -1,49 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View file

@ -1,32 +0,0 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View file

@ -1,116 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View file

@ -1,82 +0,0 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -1,66 +0,0 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -1,9 +0,0 @@
"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});
export const { signIn, signUp, signOut, useSession } = authClient;

View file

@ -1,28 +0,0 @@
import { auth } from "./auth";
import { headers } from "next/headers";
export async function getSession() {
const session = await auth.api.getSession({
headers: await headers(),
});
return session;
}
export async function requireAuth() {
const session = await getSession();
if (!session) {
return { error: "Unauthorized", status: 401 } as const;
}
return { session } as const;
}
export async function requireAdmin() {
const session = await getSession();
if (!session) {
return { error: "Unauthorized", status: 401 } as const;
}
if (session.user.role !== "ADMIN") {
return { error: "Forbidden", status: 403 } as const;
}
return { session } as const;
}

View file

@ -1,31 +0,0 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
maxPasswordLength: 64,
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "USER",
},
},
},
trustedOrigins: [
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
],
});
export type Session = typeof auth.$Infer.Session;

View file

@ -1,17 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
const adapter = new PrismaPg(process.env.DATABASE_URL!);
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

View file

@ -1,25 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, pattern = "dd MMM yyyy") {
return format(new Date(date), pattern, { locale: fr });
}
export function formatDateShort(date: Date | string) {
return format(new Date(date), "dd/MM/yyyy");
}
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}

View file

@ -1,65 +0,0 @@
import { z } from "zod/v4";
export const diseaseSchema = z.object({
name: z.string().min(1, "Nom requis").max(200).trim(),
nameEn: z.string().max(200).trim().optional().default(""),
scientificName: z.string().max(200).trim().optional().default(""),
slug: z.string().max(100).trim().optional(),
type: z.enum(["FUNGAL", "BACTERIAL", "PEST", "ABIOTIC"]),
severity: z.enum(["LOW", "MEDIUM", "HIGH"]),
description: z.string().min(1, "Description requise").trim(),
descriptionEn: z.string().trim().optional().default(""),
symptoms: z.array(z.string().trim()).min(1, "Au moins un symptome"),
symptomsEn: z.array(z.string().trim()).optional().default([]),
treatment: z.string().min(1, "Traitement requis").trim(),
treatmentEn: z.string().trim().optional().default(""),
season: z.string().min(1, "Saison requise").trim(),
seasonEn: z.string().trim().optional().default(""),
iconName: z.string().trim().optional().default("leaf"),
iconColor: z.string().trim().optional().default("#1D9E75"),
bgColor: z.string().trim().optional().default("#E1F5EE"),
imageUrl: z.string().url().optional().nullable(),
published: z.boolean().optional().default(true),
});
export const guideSchema = z.object({
title: z.string().min(1, "Titre requis").max(200).trim(),
titleEn: z.string().max(200).trim().optional().default(""),
slug: z.string().max(100).trim().optional(),
subtitle: z.string().min(1, "Sous-titre requis").max(500).trim(),
subtitleEn: z.string().max(500).trim().optional().default(""),
content: z.string().min(1, "Contenu requis").trim(),
contentEn: z.string().trim().optional().default(""),
category: z.string().trim().optional().default("general"),
iconName: z.string().trim().optional().default("book"),
iconColor: z.string().trim().optional().default("#185FA5"),
bgColor: z.string().trim().optional().default("#E6F1FB"),
published: z.boolean().optional().default(true),
order: z.number().int().min(0).optional().default(0),
});
export const alertSchema = z.object({
title: z.string().min(1, "Titre requis").max(200).trim(),
titleEn: z.string().max(200).trim().optional().default(""),
message: z.string().min(1, "Message requis").trim(),
messageEn: z.string().trim().optional().default(""),
type: z.enum(["WARNING", "INFO", "DANGER"]).optional().default("WARNING"),
region: z.string().trim().optional().default("bordeaux"),
active: z.boolean().optional().default(true),
activeFrom: z.coerce.date().optional(),
activeTo: z.coerce.date().optional().nullable(),
});
export const scanSchema = z.object({
diseaseId: z.string().optional().nullable(),
confidence: z.number().min(0).max(1),
latitude: z.number().optional().nullable(),
longitude: z.number().optional().nullable(),
imageUrl: z.string().url().optional().nullable(),
deviceId: z.string().optional().nullable(),
});
export type DiseaseInput = z.infer<typeof diseaseSchema>;
export type GuideInput = z.infer<typeof guideSchema>;
export type AlertInput = z.infer<typeof alertSchema>;
export type ScanInput = z.infer<typeof scanSchema>;

View file

@ -1,30 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
pathname.startsWith("/dashboard") ||
pathname.startsWith("/diseases") ||
pathname.startsWith("/guides") ||
pathname.startsWith("/users") ||
pathname.startsWith("/alerts")
) {
const sessionCookie = request.cookies.get("better-auth.session_token");
if (!sessionCookie) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: [
"/dashboard/:path*",
"/diseases/:path*",
"/guides/:path*",
"/users/:path*",
"/alerts/:path*",
],
};

View file

@ -1,12 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: ".",
},
typescript: {
ignoreBuildErrors: false,
},
};
export default nextConfig;

View file

@ -1,56 +0,0 @@
{
"name": "vineye-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"better-auth": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.4.0",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"shadcn": "^4.1.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/engines",
"prisma",
"esbuild"
]
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"prisma": "^7.6.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View file

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -1,12 +0,0 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View file

@ -1,208 +0,0 @@
-- CreateEnum
CREATE TYPE "DiseaseType" AS ENUM ('FUNGAL', 'BACTERIAL', 'PEST', 'ABIOTIC');
-- CreateEnum
CREATE TYPE "Severity" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateEnum
CREATE TYPE "AlertType" AS ENUM ('WARNING', 'INFO', 'DANGER');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"xp" INTEGER NOT NULL DEFAULT 0,
"level" INTEGER NOT NULL DEFAULT 1,
"banned" BOOLEAN NOT NULL DEFAULT false,
"bannedReason" TEXT,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verifications" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3),
CONSTRAINT "verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "diseases" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameEn" TEXT NOT NULL DEFAULT '',
"scientificName" TEXT NOT NULL DEFAULT '',
"type" "DiseaseType" NOT NULL,
"severity" "Severity" NOT NULL,
"description" TEXT NOT NULL,
"descriptionEn" TEXT NOT NULL DEFAULT '',
"symptoms" TEXT[],
"symptomsEn" TEXT[],
"treatment" TEXT NOT NULL,
"treatmentEn" TEXT NOT NULL DEFAULT '',
"season" TEXT NOT NULL,
"seasonEn" TEXT NOT NULL DEFAULT '',
"iconName" TEXT NOT NULL DEFAULT 'leaf',
"iconColor" TEXT NOT NULL DEFAULT '#1D9E75',
"bgColor" TEXT NOT NULL DEFAULT '#E1F5EE',
"imageUrl" TEXT,
"published" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "diseases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "guides" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"titleEn" TEXT NOT NULL DEFAULT '',
"subtitle" TEXT NOT NULL,
"subtitleEn" TEXT NOT NULL DEFAULT '',
"content" TEXT NOT NULL,
"contentEn" TEXT NOT NULL DEFAULT '',
"category" TEXT NOT NULL DEFAULT 'general',
"iconName" TEXT NOT NULL DEFAULT 'book',
"iconColor" TEXT NOT NULL DEFAULT '#185FA5',
"bgColor" TEXT NOT NULL DEFAULT '#E6F1FB',
"published" BOOLEAN NOT NULL DEFAULT true,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "guides_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "scans" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"diseaseId" TEXT,
"confidence" DOUBLE PRECISION NOT NULL DEFAULT 0,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"imageUrl" TEXT,
"deviceId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scans_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "season_alerts" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"titleEn" TEXT NOT NULL DEFAULT '',
"message" TEXT NOT NULL,
"messageEn" TEXT NOT NULL DEFAULT '',
"type" "AlertType" NOT NULL DEFAULT 'WARNING',
"region" TEXT NOT NULL DEFAULT 'bordeaux',
"active" BOOLEAN NOT NULL DEFAULT true,
"activeFrom" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"activeTo" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "season_alerts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "diseases_slug_key" ON "diseases"("slug");
-- CreateIndex
CREATE INDEX "diseases_type_idx" ON "diseases"("type");
-- CreateIndex
CREATE INDEX "diseases_severity_idx" ON "diseases"("severity");
-- CreateIndex
CREATE INDEX "diseases_published_idx" ON "diseases"("published");
-- CreateIndex
CREATE UNIQUE INDEX "guides_slug_key" ON "guides"("slug");
-- CreateIndex
CREATE INDEX "guides_published_idx" ON "guides"("published");
-- CreateIndex
CREATE INDEX "guides_order_idx" ON "guides"("order");
-- CreateIndex
CREATE INDEX "scans_userId_idx" ON "scans"("userId");
-- CreateIndex
CREATE INDEX "scans_diseaseId_idx" ON "scans"("diseaseId");
-- CreateIndex
CREATE INDEX "scans_createdAt_idx" ON "scans"("createdAt");
-- CreateIndex
CREATE INDEX "season_alerts_active_idx" ON "season_alerts"("active");
-- CreateIndex
CREATE INDEX "season_alerts_region_idx" ON "season_alerts"("region");
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scans" ADD CONSTRAINT "scans_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scans" ADD CONSTRAINT "scans_diseaseId_fkey" FOREIGN KEY ("diseaseId") REFERENCES "diseases"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View file

@ -1,193 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ============================================
// BETTER AUTH MODELS
// ============================================
model User {
id String @id
name String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// VinEye custom fields
role String @default("USER")
xp Int @default(0)
level Int @default(1)
banned Boolean @default(false)
bannedReason String?
sessions Session[]
accounts Account[]
scans Scan[]
@@map("users")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("accounts")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@map("verifications")
}
// ============================================
// VINEYE BUSINESS MODELS
// ============================================
enum DiseaseType {
FUNGAL
BACTERIAL
PEST
ABIOTIC
}
enum Severity {
LOW
MEDIUM
HIGH
}
enum AlertType {
WARNING
INFO
DANGER
}
model Disease {
id String @id @default(cuid())
slug String @unique
name String
nameEn String @default("")
scientificName String @default("")
type DiseaseType
severity Severity
description String @db.Text
descriptionEn String @default("") @db.Text
symptoms String[]
symptomsEn String[]
treatment String @db.Text
treatmentEn String @default("") @db.Text
season String
seasonEn String @default("")
iconName String @default("leaf")
iconColor String @default("#1D9E75")
bgColor String @default("#E1F5EE")
imageUrl String?
published Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scans Scan[]
@@index([type])
@@index([severity])
@@index([published])
@@map("diseases")
}
model Guide {
id String @id @default(cuid())
slug String @unique
title String
titleEn String @default("")
subtitle String
subtitleEn String @default("")
content String @db.Text
contentEn String @default("") @db.Text
category String @default("general")
iconName String @default("book")
iconColor String @default("#185FA5")
bgColor String @default("#E6F1FB")
published Boolean @default(true)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([published])
@@index([order])
@@map("guides")
}
model Scan {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
diseaseId String?
disease Disease? @relation(fields: [diseaseId], references: [id], onDelete: SetNull)
confidence Float @default(0)
latitude Float?
longitude Float?
imageUrl String?
deviceId String?
createdAt DateTime @default(now())
@@index([userId])
@@index([diseaseId])
@@index([createdAt])
@@map("scans")
}
model SeasonAlert {
id String @id @default(cuid())
title String
titleEn String @default("")
message String @db.Text
messageEn String @default("") @db.Text
type AlertType @default(WARNING)
region String @default("bordeaux")
active Boolean @default(true)
activeFrom DateTime @default(now())
activeTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([active])
@@index([region])
@@map("season_alerts")
}

View file

@ -1,421 +0,0 @@
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { hashPassword } from "better-auth/crypto";
const adapter = new PrismaPg(process.env.DATABASE_URL!);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log("Seeding database...");
// 1. Admin user
const passwordHash = await hashPassword("admin123456");
const admin = await prisma.user.upsert({
where: { email: "admin@vineye.app" },
update: {},
create: {
id: "admin-001",
name: "Admin VinEye",
email: "admin@vineye.app",
role: "ADMIN",
emailVerified: true,
xp: 5000,
level: 10,
},
});
// Create account for Better Auth email/password login
await prisma.account.upsert({
where: { id: "account-admin-001" },
update: {},
create: {
id: "account-admin-001",
accountId: admin.id,
providerId: "credential",
userId: admin.id,
password: passwordHash,
},
});
console.log(" Admin user created: admin@vineye.app / admin123456");
// 2. Test user
const userPasswordHash = await hashPassword("user123456");
const testUser = await prisma.user.upsert({
where: { email: "jean@vineye.app" },
update: {},
create: {
id: "user-001",
name: "Jean Dupont",
email: "jean@vineye.app",
role: "USER",
emailVerified: true,
xp: 1250,
level: 4,
},
});
await prisma.account.upsert({
where: { id: "account-user-001" },
update: {},
create: {
id: "account-user-001",
accountId: testUser.id,
providerId: "credential",
userId: testUser.id,
password: userPasswordHash,
},
});
console.log(" Test user created: jean@vineye.app / user123456");
// 3. Diseases
const diseases = [
{
slug: "mildiou",
name: "Mildiou",
nameEn: "Downy Mildew",
scientificName: "Plasmopara viticola",
type: "FUNGAL" as const,
severity: "HIGH" as const,
description:
"Le mildiou est cause par le champignon Plasmopara viticola. Il attaque toutes les parties vertes de la vigne, principalement les feuilles.",
descriptionEn:
"Downy mildew is caused by the fungus Plasmopara viticola. It attacks all green parts of the vine, mainly the leaves.",
symptoms: [
"Taches jaunes huileuses sur la face superieure des feuilles",
"Duvet blanc cotonneux sur la face inferieure",
"Dessechement et chute prematuree des feuilles",
],
symptomsEn: [
"Oily yellow spots on the upper side of leaves",
"White cottony fuzz on the underside",
"Drying and premature leaf drop",
],
treatment:
"Traitement preventif a base de cuivre (bouillie bordelaise). Appliquer avant les pluies, renouveler tous les 10-14 jours.",
treatmentEn:
"Preventive copper-based treatment (Bordeaux mixture). Apply before rain, repeat every 10-14 days.",
season: "Mai a aout — favorise par la chaleur et l'humidite",
seasonEn: "May to August — favored by heat and humidity",
iconName: "droplets",
iconColor: "#1D9E75",
bgColor: "#E1F5EE",
},
{
slug: "oidium",
name: "Oidium",
nameEn: "Powdery Mildew",
scientificName: "Erysiphe necator",
type: "FUNGAL" as const,
severity: "HIGH" as const,
description:
"L'oidium est cause par Erysiphe necator. Il se developpe par temps chaud et sec, contrairement au mildiou.",
descriptionEn:
"Powdery mildew is caused by Erysiphe necator. It develops in hot, dry weather, unlike downy mildew.",
symptoms: [
"Poudre blanche-grisatre sur feuilles et grappes",
"Baies qui eclatent ou se dessechent",
],
symptomsEn: [
"White-grayish powder on leaves and clusters",
"Berries that crack or dry out",
],
treatment:
"Soufre en poudrage ou pulverisation. Traitements preventifs des le debourrement.",
treatmentEn:
"Sulfur dusting or spraying. Preventive treatments from bud break.",
season: "Avril a septembre — favorise par temps chaud et sec",
seasonEn: "April to September — favored by hot and dry weather",
iconName: "wind",
iconColor: "#8B5CF6",
bgColor: "#F3EEFF",
},
{
slug: "black-rot",
name: "Black rot",
nameEn: "Black Rot",
scientificName: "Guignardia bidwellii",
type: "FUNGAL" as const,
severity: "HIGH" as const,
description:
"Le black rot est cause par Guignardia bidwellii. Il provoque des degats importants sur les baies.",
descriptionEn:
"Black rot is caused by Guignardia bidwellii. It causes significant damage to berries.",
symptoms: [
"Taches brunes circulaires bordees de noir sur les feuilles",
"Baies momifiees, noires et ridees",
],
symptomsEn: [
"Circular brown spots bordered in black on leaves",
"Mummified, black and wrinkled berries",
],
treatment:
"Eliminer les baies momifiees. Traitements fongicides preventifs au printemps.",
treatmentEn:
"Remove mummified berries. Preventive fungicide treatments in spring.",
season: "Mai a juillet — favorise par les pluies printanieres",
seasonEn: "May to July — favored by spring rains",
iconName: "circle",
iconColor: "#1A1A1A",
bgColor: "#F0F0F0",
},
{
slug: "esca",
name: "Esca",
nameEn: "Esca Disease",
scientificName: "Phaeomoniella chlamydospora",
type: "FUNGAL" as const,
severity: "MEDIUM" as const,
description:
"L'esca est un complexe de maladies du bois cause par plusieurs champignons. Maladie chronique qui peut tuer le cep.",
descriptionEn:
"Esca is a complex of wood diseases caused by several fungi. A chronic disease that can kill the vine.",
symptoms: [
"Decolorations entre les nervures des feuilles (aspect tigre)",
"Dessechement brutal du feuillage (apoplexie)",
],
symptomsEn: [
"Discoloration between leaf veins (tiger stripe pattern)",
"Sudden foliage desiccation (apoplexy)",
],
treatment:
"Aucun traitement curatif. Recepage du cep atteint. Proteger les plaies de taille.",
treatmentEn:
"No curative treatment. Trunk renewal of affected vines. Protect pruning wounds.",
season: "Symptomes visibles en ete — juin a septembre",
seasonEn: "Symptoms visible in summer — June to September",
iconName: "tree-deciduous",
iconColor: "#B45309",
bgColor: "#FEF3C7",
},
{
slug: "botrytis",
name: "Botrytis",
nameEn: "Botrytis (Gray Mold)",
scientificName: "Botrytis cinerea",
type: "FUNGAL" as const,
severity: "MEDIUM" as const,
description:
"La pourriture grise est causee par Botrytis cinerea. Elle attaque les grappes a maturite.",
descriptionEn:
"Gray mold is caused by Botrytis cinerea. It attacks clusters at maturity.",
symptoms: [
"Pourriture molle grise sur les baies",
"Feutrage gris caracteristique sur les grappes",
],
symptomsEn: [
"Soft gray rot on berries",
"Characteristic gray felt on clusters",
],
treatment:
"Favoriser l'aeration des grappes. Effeuillage. Traitements anti-botrytis avant fermeture de la grappe.",
treatmentEn:
"Promote cluster ventilation. Leaf removal. Anti-botrytis treatments before cluster closure.",
season: "Aout a vendanges — favorise par l'humidite",
seasonEn: "August to harvest — favored by humidity",
iconName: "cloud-rain",
iconColor: "#6B7280",
bgColor: "#F3F4F6",
},
{
slug: "flavescence-doree",
name: "Flavescence doree",
nameEn: "Flavescence Doree",
scientificName: "Phytoplasma vitis",
type: "BACTERIAL" as const,
severity: "HIGH" as const,
description:
"Maladie a phytoplasme transmise par la cicadelle Scaphoideus titanus. Maladie reglementee, declaration obligatoire.",
descriptionEn:
"Phytoplasma disease transmitted by the Scaphoideus titanus leafhopper. Regulated disease, mandatory reporting.",
symptoms: [
"Enroulement des feuilles avec coloration jaune ou rouge selon le cepage",
"Non-aoutement des rameaux (restent caoutchouteux)",
],
symptomsEn: [
"Leaf curling with yellow or red coloring depending on cultivar",
"Non-lignification of shoots (remain rubbery)",
],
treatment:
"Arrachage obligatoire des ceps contamines. Traitement insecticide contre la cicadelle vectrice.",
treatmentEn:
"Mandatory uprooting of contaminated vines. Insecticide treatment against the vector leafhopper.",
season: "Symptomes visibles a partir de juillet",
seasonEn: "Symptoms visible from July",
iconName: "bug",
iconColor: "#DC2626",
bgColor: "#FEE2E2",
},
{
slug: "chlorose-ferrique",
name: "Chlorose ferrique",
nameEn: "Iron Chlorosis",
scientificName: "",
type: "ABIOTIC" as const,
severity: "LOW" as const,
description:
"Jaunissement des feuilles du a une carence en fer, souvent lie a un sol trop calcaire.",
descriptionEn:
"Yellowing of leaves due to iron deficiency, often linked to overly calcareous soil.",
symptoms: [
"Jaunissement entre les nervures, nervures restant vertes",
"Affaiblissement general de la vigne",
],
symptomsEn: [
"Yellowing between veins while veins remain green",
"General weakening of the vine",
],
treatment:
"Apport de chelates de fer. Choix d'un porte-greffe adapte aux sols calcaires.",
treatmentEn:
"Iron chelate application. Choice of rootstock adapted to calcareous soils.",
season: "Printemps — surtout sur sols calcaires apres de fortes pluies",
seasonEn:
"Spring — especially on calcareous soils after heavy rain",
iconName: "leaf",
iconColor: "#EAB308",
bgColor: "#FEF9C3",
},
];
for (const disease of diseases) {
await prisma.disease.upsert({
where: { slug: disease.slug },
update: disease,
create: disease,
});
}
console.log(` ${diseases.length} diseases seeded`);
// 4. Guides
const guides = [
{
slug: "reconnaitre-feuille-saine",
title: "Reconnaitre une feuille saine",
titleEn: "Recognizing a Healthy Leaf",
subtitle: "Les bases pour identifier une vigne en bonne sante",
subtitleEn: "Basics for identifying a healthy vine",
content:
"Une feuille de vigne saine presente une couleur verte uniforme, sans taches ni decolorations. Les nervures sont nettes et la texture est ferme au toucher. Apprenez a reperer les premiers signes de stress pour agir rapidement.",
contentEn:
"A healthy vine leaf shows uniform green color, without spots or discolorations. Veins are clear and the texture is firm to the touch. Learn to spot the first signs of stress to act quickly.",
category: "diagnostic",
iconName: "leaf",
iconColor: "#1D9E75",
bgColor: "#E1F5EE",
order: 0,
},
{
slug: "calendrier-traitement",
title: "Calendrier de traitement",
titleEn: "Treatment Calendar",
subtitle: "Quand et comment traiter tout au long de la saison",
subtitleEn: "When and how to treat throughout the season",
content:
"Un calendrier detaille des traitements phytosanitaires pour la vigne, du debourrement aux vendanges. Inclut les periodes cles pour la prevention du mildiou, de l'oidium et du botrytis.",
contentEn:
"A detailed calendar of phytosanitary treatments for vines, from bud break to harvest. Includes key periods for prevention of downy mildew, powdery mildew and botrytis.",
category: "traitement",
iconName: "calendar",
iconColor: "#185FA5",
bgColor: "#E6F1FB",
order: 1,
},
{
slug: "cepages-bordelais",
title: "Les cepages bordelais",
titleEn: "Bordeaux Grape Varieties",
subtitle: "Guide des principales varietes de la region bordelaise",
subtitleEn: "Guide to the main varieties of the Bordeaux region",
content:
"Decouvrez les principaux cepages bordelais : Merlot, Cabernet Sauvignon, Cabernet Franc, Petit Verdot, Malbec pour les rouges, et Sauvignon Blanc, Semillon, Muscadelle pour les blancs.",
contentEn:
"Discover the main Bordeaux grape varieties: Merlot, Cabernet Sauvignon, Cabernet Franc, Petit Verdot, Malbec for reds, and Sauvignon Blanc, Semillon, Muscadelle for whites.",
category: "cepages",
iconName: "grape",
iconColor: "#7C3AED",
bgColor: "#F3EEFF",
order: 2,
},
];
for (const guide of guides) {
await prisma.guide.upsert({
where: { slug: guide.slug },
update: guide,
create: guide,
});
}
console.log(` ${guides.length} guides seeded`);
// 5. Season alerts
const alerts = [
{
title: "Risque mildiou eleve",
titleEn: "High Downy Mildew Risk",
message:
"Les conditions meteo actuelles (chaleur + humidite) sont tres favorables au developpement du mildiou. Surveillez vos vignes et appliquez un traitement preventif si necessaire.",
messageEn:
"Current weather conditions (heat + humidity) are very favorable for downy mildew development. Monitor your vines and apply preventive treatment if necessary.",
type: "WARNING" as const,
region: "bordeaux",
active: true,
},
{
title: "Debut de la saison de traitement",
titleEn: "Start of Treatment Season",
message:
"La saison de traitement phytosanitaire debute. Consultez le calendrier de traitement pour planifier vos interventions.",
messageEn:
"The phytosanitary treatment season begins. Check the treatment calendar to plan your interventions.",
type: "INFO" as const,
region: "bordeaux",
active: true,
},
];
for (const alert of alerts) {
await prisma.seasonAlert.create({ data: alert });
}
console.log(` ${alerts.length} season alerts seeded`);
// 6. Mock scans
const allDiseases = await prisma.disease.findMany({ select: { id: true } });
const now = new Date();
for (let i = 0; i < 10; i++) {
const daysAgo = Math.floor(Math.random() * 30);
const date = new Date(now);
date.setDate(date.getDate() - daysAgo);
const randomDisease =
allDiseases[Math.floor(Math.random() * allDiseases.length)];
await prisma.scan.create({
data: {
userId: i < 7 ? testUser.id : admin.id,
diseaseId: randomDisease.id,
confidence: 0.6 + Math.random() * 0.35,
latitude: 44.8378 + (Math.random() - 0.5) * 0.1,
longitude: -0.5792 + (Math.random() - 0.5) * 0.1,
createdAt: date,
},
});
}
console.log(" 10 mock scans seeded");
console.log("\nSeed completed!");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

Some files were not shown because too many files have changed in this diff Show more