Compare commits
No commits in common. "fe70005a86f7095d5e60f104bd6a3e22f50c2dac" and "af299e816abc0ddb96f934e2bdd6f80fb1c1645c" have entirely different histories.
fe70005a86
...
af299e816a
|
|
@ -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 -->
|
||||
|
|
@ -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
11
.gitignore
vendored
|
|
@ -24,12 +24,5 @@ VinEye/dist/
|
|||
VinEye/ios/
|
||||
VinEye/android/
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# vineye-admin
|
||||
vineye-admin/node_modules/
|
||||
vineye-admin/.next/
|
||||
# dependances
|
||||
node_modules/
|
||||
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -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
|
||||
|
|
@ -38,10 +38,10 @@ export default function SearchHeader() {
|
|||
<TouchableOpacity
|
||||
style={styles.notifButton}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => navigation.navigate("Settings")}
|
||||
onPress={() => navigation.navigate("Profile")}
|
||||
>
|
||||
<Ionicons
|
||||
name="settings-outline"
|
||||
name="person-outline"
|
||||
size={22}
|
||||
color={colors.neutral[800]}
|
||||
/>
|
||||
|
|
@ -65,13 +65,13 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
brandTitle: {
|
||||
fontSize: 24,
|
||||
fontSize: 32,
|
||||
fontWeight: "900", // Très gras pour l'identité
|
||||
color: colors.primary[900],
|
||||
letterSpacing: -1, // Look "Logo"
|
||||
},
|
||||
greetingText: {
|
||||
fontSize: 14,
|
||||
fontSize: 10,
|
||||
fontWeight: "500",
|
||||
color: colors.neutral[500],
|
||||
marginTop: -2,
|
||||
|
|
@ -81,16 +81,27 @@ const styles = StyleSheet.create({
|
|||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#F0F0F0",
|
||||
borderRadius: 32,
|
||||
borderRadius: 16,
|
||||
},
|
||||
notifButton: {
|
||||
height: 48,
|
||||
width: 48,
|
||||
alignItems: "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: {
|
||||
position: "absolute",
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ const styles = StyleSheet.create({
|
|||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
|
||||
fontWeight: "500",
|
||||
color: colors.neutral[900],
|
||||
// Évite le décalage de texte sur Android
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const styles = StyleSheet.create({
|
|||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
|
||||
marginBottom: 16, // Espace constant sous le header
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
title: {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
|
|||
backgroundColor: colors.primary[800],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: -25,
|
||||
marginTop: -28,
|
||||
shadowColor: colors.primary[900],
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
|
|
@ -122,7 +122,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
|
|||
strokeWidth={isFocused ? 2.5 : 1.8}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
{/* <Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
|
|
@ -132,7 +132,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
|
|||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Text> */}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.7.0",
|
||||
"lucide-react-native": "^1.7.0"
|
||||
}
|
||||
}
|
||||
2741
pnpm-lock.yaml
2741
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
43
vineye-admin/.gitignore
vendored
43
vineye-admin/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -1 +0,0 @@
|
|||
@AGENTS.md
|
||||
|
|
@ -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.
|
||||
|
|
@ -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] : "",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import AlertForm from "@/components/admin/alert-form";
|
||||
|
||||
export default function NewAlertPage() {
|
||||
return <AlertForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import DiseaseForm from "@/components/admin/disease-form";
|
||||
|
||||
export default function NewDiseasePage() {
|
||||
return <DiseaseForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import GuideForm from "@/components/admin/guide-form";
|
||||
|
||||
export default function NewGuidePage() {
|
||||
return <GuideForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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'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'acces a l'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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, "");
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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*",
|
||||
],
|
||||
};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: ".",
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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"],
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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
Loading…
Reference in a new issue