refactor: standardize quotation marks across all files and improve code consistency

This commit is contained in:
Julien Froidefond
2025-11-27 11:40:30 +01:00
parent cc1e8c20a6
commit b2efade4d5
107 changed files with 9471 additions and 5952 deletions

View File

@@ -32,17 +32,20 @@ Application web moderne de gestion personnelle de comptes bancaires avec import
## 🔧 Installation ## 🔧 Installation
1. Clonez le dépôt : 1. Clonez le dépôt :
```bash ```bash
git clone <url-du-repo> git clone <url-du-repo>
cd bank-account-management-app cd bank-account-management-app
``` ```
2. Installez les dépendances : 2. Installez les dépendances :
```bash ```bash
pnpm install pnpm install
``` ```
3. Lancez le serveur de développement : 3. Lancez le serveur de développement :
```bash ```bash
pnpm dev pnpm dev
``` ```
@@ -99,6 +102,7 @@ L'application détecte automatiquement les doublons basés sur l'ID unique (FITI
### Catégories par défaut ### Catégories par défaut
L'application inclut des catégories pré-configurées avec des mots-clés pour la catégorisation automatique : L'application inclut des catégories pré-configurées avec des mots-clés pour la catégorisation automatique :
- Alimentation - Alimentation
- Transport - Transport
- Logement - Logement
@@ -132,6 +136,7 @@ Le thème sombre/clair peut être changé dans les paramètres. L'application d
### Structure des données ### Structure des données
Les données sont structurées comme suit : Les données sont structurées comme suit :
- **Accounts** : Comptes bancaires avec solde et métadonnées - **Accounts** : Comptes bancaires avec solde et métadonnées
- **Transactions** : Transactions avec montant, date, description, catégorie - **Transactions** : Transactions avec montant, date, description, catégorie
- **Folders** : Dossiers pour organiser les comptes - **Folders** : Dossiers pour organiser les comptes
@@ -149,4 +154,3 @@ Ce projet est en développement actif. Les suggestions et améliorations sont le
--- ---
Développé avec ❤️ en utilisant Next.js et React Développé avec ❤️ en utilisant Next.js et React

View File

@@ -1,43 +1,68 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks";
import { updateAccount, deleteAccount } from "@/lib/store-db" import { updateAccount, deleteAccount } from "@/lib/store-db";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" Dialog,
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" DialogContent,
import { MoreVertical, Pencil, Trash2, Building2, CreditCard, Wallet, PiggyBank, RefreshCw } from "lucide-react" DialogHeader,
import type { Account } from "@/lib/types" DialogTitle,
import { cn } from "@/lib/utils" } from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
MoreVertical,
Pencil,
Trash2,
Building2,
CreditCard,
Wallet,
PiggyBank,
RefreshCw,
} from "lucide-react";
import type { Account } from "@/lib/types";
import { cn } from "@/lib/utils";
const accountTypeIcons = { const accountTypeIcons = {
CHECKING: Wallet, CHECKING: Wallet,
SAVINGS: PiggyBank, SAVINGS: PiggyBank,
CREDIT_CARD: CreditCard, CREDIT_CARD: CreditCard,
OTHER: Building2, OTHER: Building2,
} };
const accountTypeLabels = { const accountTypeLabels = {
CHECKING: "Compte courant", CHECKING: "Compte courant",
SAVINGS: "Épargne", SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit", CREDIT_CARD: "Carte de crédit",
OTHER: "Autre", OTHER: "Autre",
} };
export default function AccountsPage() { export default function AccountsPage() {
const { data, isLoading, refresh, update } = useBankingData() const { data, isLoading, refresh, update } = useBankingData();
const [editingAccount, setEditingAccount] = useState<Account | null>(null) const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
type: "CHECKING" as Account["type"], type: "CHECKING" as Account["type"],
folderId: "folder-root", folderId: "folder-root",
}) });
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -47,28 +72,28 @@ export default function AccountsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" /> <RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main> </main>
</div> </div>
) );
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(amount) }).format(amount);
} };
const handleEdit = (account: Account) => { const handleEdit = (account: Account) => {
setEditingAccount(account) setEditingAccount(account);
setFormData({ setFormData({
name: account.name, name: account.name,
type: account.type, type: account.type,
folderId: account.folderId || "folder-root", folderId: account.folderId || "folder-root",
}) });
setIsDialogOpen(true) setIsDialogOpen(true);
} };
const handleSave = async () => { const handleSave = async () => {
if (!editingAccount) return if (!editingAccount) return;
try { try {
const updatedAccount = { const updatedAccount = {
@@ -76,34 +101,34 @@ export default function AccountsPage() {
name: formData.name, name: formData.name,
type: formData.type, type: formData.type,
folderId: formData.folderId, folderId: formData.folderId,
} };
await updateAccount(updatedAccount) await updateAccount(updatedAccount);
refresh() refresh();
setIsDialogOpen(false) setIsDialogOpen(false);
setEditingAccount(null) setEditingAccount(null);
} catch (error) { } catch (error) {
console.error("Error updating account:", error) console.error("Error updating account:", error);
alert("Erreur lors de la mise à jour du compte") alert("Erreur lors de la mise à jour du compte");
}
} }
};
const handleDelete = async (accountId: string) => { const handleDelete = async (accountId: string) => {
if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return;
try { try {
await deleteAccount(accountId) await deleteAccount(accountId);
refresh() refresh();
} catch (error) { } catch (error) {
console.error("Error deleting account:", error) console.error("Error deleting account:", error);
alert("Erreur lors de la suppression du compte") alert("Erreur lors de la suppression du compte");
}
} }
};
const getTransactionCount = (accountId: string) => { const getTransactionCount = (accountId: string) => {
return data.transactions.filter((t) => t.accountId === accountId).length return data.transactions.filter((t) => t.accountId === accountId).length;
} };
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0) const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
@@ -113,11 +138,18 @@ export default function AccountsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Comptes</h1> <h1 className="text-2xl font-bold text-foreground">Comptes</h1>
<p className="text-muted-foreground">Gérez vos comptes bancaires</p> <p className="text-muted-foreground">
Gérez vos comptes bancaires
</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm text-muted-foreground">Solde total</p> <p className="text-sm text-muted-foreground">Solde total</p>
<p className={cn("text-2xl font-bold", totalBalance >= 0 ? "text-emerald-600" : "text-red-600")}> <p
className={cn(
"text-2xl font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(totalBalance)} {formatCurrency(totalBalance)}
</p> </p>
</div> </div>
@@ -129,15 +161,18 @@ export default function AccountsPage() {
<Building2 className="w-16 h-16 text-muted-foreground mb-4" /> <Building2 className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Aucun compte</h3> <h3 className="text-lg font-semibold mb-2">Aucun compte</h3>
<p className="text-muted-foreground text-center mb-4"> <p className="text-muted-foreground text-center mb-4">
Importez un fichier OFX depuis le tableau de bord pour ajouter votre premier compte. Importez un fichier OFX depuis le tableau de bord pour ajouter
votre premier compte.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.accounts.map((account) => { {data.accounts.map((account) => {
const Icon = accountTypeIcons[account.type] const Icon = accountTypeIcons[account.type];
const folder = data.folders.find((f) => f.id === account.folderId) const folder = data.folders.find(
(f) => f.id === account.folderId,
);
return ( return (
<Card key={account.id} className="relative"> <Card key={account.id} className="relative">
@@ -148,22 +183,35 @@ export default function AccountsPage() {
<Icon className="w-5 h-5 text-primary" /> <Icon className="w-5 h-5 text-primary" />
</div> </div>
<div> <div>
<CardTitle className="text-base">{account.name}</CardTitle> <CardTitle className="text-base">
<p className="text-xs text-muted-foreground">{accountTypeLabels[account.type]}</p> {account.name}
</CardTitle>
<p className="text-xs text-muted-foreground">
{accountTypeLabels[account.type]}
</p>
</div> </div>
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(account)}> <DropdownMenuItem
onClick={() => handleEdit(account)}
>
<Pencil className="w-4 h-4 mr-2" /> <Pencil className="w-4 h-4 mr-2" />
Modifier Modifier
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(account.id)} className="text-red-600"> <DropdownMenuItem
onClick={() => handleDelete(account.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Supprimer Supprimer
</DropdownMenuItem> </DropdownMenuItem>
@@ -175,23 +223,30 @@ export default function AccountsPage() {
<div <div
className={cn( className={cn(
"text-2xl font-bold mb-2", "text-2xl font-bold mb-2",
account.balance >= 0 ? "text-emerald-600" : "text-red-600", account.balance >= 0
? "text-emerald-600"
: "text-red-600",
)} )}
> >
{formatCurrency(account.balance)} {formatCurrency(account.balance)}
</div> </div>
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{getTransactionCount(account.id)} transactions</span> <span>
{getTransactionCount(account.id)} transactions
</span>
{folder && <span>{folder.name}</span>} {folder && <span>{folder.name}</span>}
</div> </div>
{account.lastImport && ( {account.lastImport && (
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Dernier import: {new Date(account.lastImport).toLocaleDateString("fr-FR")} Dernier import:{" "}
{new Date(account.lastImport).toLocaleDateString(
"fr-FR",
)}
</p> </p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) );
})} })}
</div> </div>
)} )}
@@ -206,13 +261,20 @@ export default function AccountsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Nom du compte</Label> <Label>Nom du compte</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} /> <Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type de compte</Label> <Label>Type de compte</Label>
<Select <Select
value={formData.type} value={formData.type}
onValueChange={(v) => setFormData({ ...formData, type: v as Account["type"] })} onValueChange={(v) =>
setFormData({ ...formData, type: v as Account["type"] })
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -228,7 +290,10 @@ export default function AccountsPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Dossier</Label> <Label>Dossier</Label>
<Select value={formData.folderId} onValueChange={(v) => setFormData({ ...formData, folderId: v })}> <Select
value={formData.folderId}
onValueChange={(v) => setFormData({ ...formData, folderId: v })}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -251,5 +316,5 @@ export default function AccountsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@@ -1,42 +1,54 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { accountService } from "@/services/account.service" import { accountService } from "@/services/account.service";
import type { Account } from "@/lib/types" import type { Account } from "@/lib/types";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const data: Omit<Account, "id"> = await request.json() const data: Omit<Account, "id"> = await request.json();
const created = await accountService.create(data) const created = await accountService.create(data);
return NextResponse.json(created) return NextResponse.json(created);
} catch (error) { } catch (error) {
console.error("Error creating account:", error) console.error("Error creating account:", error);
return NextResponse.json({ error: "Failed to create account" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to create account" },
{ status: 500 },
);
} }
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const account: Account = await request.json() const account: Account = await request.json();
const updated = await accountService.update(account.id, account) const updated = await accountService.update(account.id, account);
return NextResponse.json(updated) return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error("Error updating account:", error) console.error("Error updating account:", error);
return NextResponse.json({ error: "Failed to update account" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to update account" },
{ status: 500 },
);
} }
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url);
const id = searchParams.get("id") const id = searchParams.get("id");
if (!id) { if (!id) {
return NextResponse.json({ error: "Account ID is required" }, { status: 400 }) return NextResponse.json(
{ error: "Account ID is required" },
{ status: 400 },
);
} }
await accountService.delete(id) await accountService.delete(id);
return NextResponse.json({ success: true }) return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error deleting account:", error) console.error("Error deleting account:", error);
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to delete account" },
{ status: 500 },
);
} }
} }

View File

@@ -1,42 +1,54 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { categoryService } from "@/services/category.service" import { categoryService } from "@/services/category.service";
import type { Category } from "@/lib/types" import type { Category } from "@/lib/types";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const data: Omit<Category, "id"> = await request.json() const data: Omit<Category, "id"> = await request.json();
const created = await categoryService.create(data) const created = await categoryService.create(data);
return NextResponse.json(created) return NextResponse.json(created);
} catch (error) { } catch (error) {
console.error("Error creating category:", error) console.error("Error creating category:", error);
return NextResponse.json({ error: "Failed to create category" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to create category" },
{ status: 500 },
);
} }
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const category: Category = await request.json() const category: Category = await request.json();
const updated = await categoryService.update(category.id, category) const updated = await categoryService.update(category.id, category);
return NextResponse.json(updated) return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error("Error updating category:", error) console.error("Error updating category:", error);
return NextResponse.json({ error: "Failed to update category" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to update category" },
{ status: 500 },
);
} }
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url);
const id = searchParams.get("id") const id = searchParams.get("id");
if (!id) { if (!id) {
return NextResponse.json({ error: "Category ID is required" }, { status: 400 }) return NextResponse.json(
{ error: "Category ID is required" },
{ status: 400 },
);
} }
await categoryService.delete(id) await categoryService.delete(id);
return NextResponse.json({ success: true }) return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error deleting category:", error) console.error("Error deleting category:", error);
return NextResponse.json({ error: "Failed to delete category" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to delete category" },
{ status: 500 },
);
} }
} }

View File

@@ -1,45 +1,57 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { folderService, FolderNotFoundError } from "@/services/folder.service" import { folderService, FolderNotFoundError } from "@/services/folder.service";
import type { Folder } from "@/lib/types" import type { Folder } from "@/lib/types";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const data: Omit<Folder, "id"> = await request.json() const data: Omit<Folder, "id"> = await request.json();
const created = await folderService.create(data) const created = await folderService.create(data);
return NextResponse.json(created) return NextResponse.json(created);
} catch (error) { } catch (error) {
console.error("Error creating folder:", error) console.error("Error creating folder:", error);
return NextResponse.json({ error: "Failed to create folder" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to create folder" },
{ status: 500 },
);
} }
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const folder: Folder = await request.json() const folder: Folder = await request.json();
const updated = await folderService.update(folder.id, folder) const updated = await folderService.update(folder.id, folder);
return NextResponse.json(updated) return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error("Error updating folder:", error) console.error("Error updating folder:", error);
return NextResponse.json({ error: "Failed to update folder" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to update folder" },
{ status: 500 },
);
} }
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url);
const id = searchParams.get("id") const id = searchParams.get("id");
if (!id) { if (!id) {
return NextResponse.json({ error: "Folder ID is required" }, { status: 400 }) return NextResponse.json(
{ error: "Folder ID is required" },
{ status: 400 },
);
} }
await folderService.delete(id) await folderService.delete(id);
return NextResponse.json({ success: true }) return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof FolderNotFoundError) { if (error instanceof FolderNotFoundError) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 }) return NextResponse.json({ error: "Folder not found" }, { status: 404 });
} }
console.error("Error deleting folder:", error) console.error("Error deleting folder:", error);
return NextResponse.json({ error: "Failed to delete folder" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to delete folder" },
{ status: 500 },
);
} }
} }

View File

@@ -1,12 +1,15 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { bankingService } from "@/services/banking.service" import { bankingService } from "@/services/banking.service";
export async function GET() { export async function GET() {
try { try {
const data = await bankingService.getAllData() const data = await bankingService.getAllData();
return NextResponse.json(data) return NextResponse.json(data);
} catch (error) { } catch (error) {
console.error("Error fetching banking data:", error) console.error("Error fetching banking data:", error);
return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to fetch data" },
{ status: 500 },
);
} }
} }

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
export async function POST() { export async function POST() {
try { try {
@@ -10,15 +10,17 @@ export async function POST() {
data: { data: {
categoryId: null, categoryId: null,
}, },
}) });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
count: result.count, count: result.count,
}) });
} catch (error) { } catch (error) {
console.error("Error clearing categories:", error) console.error("Error clearing categories:", error);
return NextResponse.json({ error: "Failed to clear categories" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to clear categories" },
{ status: 500 },
);
} }
} }

View File

@@ -1,42 +1,57 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { transactionService } from "@/services/transaction.service" import { transactionService } from "@/services/transaction.service";
import type { Transaction } from "@/lib/types" import type { Transaction } from "@/lib/types";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const transactions: Transaction[] = await request.json() const transactions: Transaction[] = await request.json();
const result = await transactionService.createMany(transactions) const result = await transactionService.createMany(transactions);
return NextResponse.json(result) return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error("Error creating transactions:", error) console.error("Error creating transactions:", error);
return NextResponse.json({ error: "Failed to create transactions" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to create transactions" },
{ status: 500 },
);
} }
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const transaction: Transaction = await request.json() const transaction: Transaction = await request.json();
const updated = await transactionService.update(transaction.id, transaction) const updated = await transactionService.update(
return NextResponse.json(updated) transaction.id,
transaction,
);
return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error("Error updating transaction:", error) console.error("Error updating transaction:", error);
return NextResponse.json({ error: "Failed to update transaction" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to update transaction" },
{ status: 500 },
);
} }
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url);
const id = searchParams.get("id") const id = searchParams.get("id");
if (!id) { if (!id) {
return NextResponse.json({ error: "Transaction ID is required" }, { status: 400 }) return NextResponse.json(
{ error: "Transaction ID is required" },
{ status: 400 },
);
} }
await transactionService.delete(id) await transactionService.delete(id);
return NextResponse.json({ success: true }) return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error deleting transaction:", error) console.error("Error deleting transaction:", error);
return NextResponse.json({ error: "Failed to delete transaction" }, { status: 500 }) return NextResponse.json(
{ error: "Failed to delete transaction" },
{ status: 500 },
);
} }
} }

View File

@@ -1,78 +1,134 @@
"use client" "use client";
import { useState, useMemo } from "react" import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" Dialog,
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" DialogContent,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" DialogHeader,
import { Plus, MoreVertical, Pencil, Trash2, RefreshCw, X, ChevronDown, ChevronRight, ChevronsUpDown, Search } from "lucide-react" DialogTitle,
import { CategoryIcon } from "@/components/ui/category-icon" } from "@/components/ui/dialog";
import { autoCategorize, addCategory, updateCategory, deleteCategory } from "@/lib/store-db" import {
import type { Category } from "@/lib/types" DropdownMenu,
import { cn } from "@/lib/utils" DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
MoreVertical,
Pencil,
Trash2,
RefreshCw,
X,
ChevronDown,
ChevronRight,
ChevronsUpDown,
Search,
} from "lucide-react";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
autoCategorize,
addCategory,
updateCategory,
deleteCategory,
} from "@/lib/store-db";
import type { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
const categoryColors = [ const categoryColors = [
"#22c55e", "#3b82f6", "#f59e0b", "#ec4899", "#ef4444", "#22c55e",
"#8b5cf6", "#06b6d4", "#84cc16", "#f97316", "#6366f1", "#3b82f6",
"#14b8a6", "#f43f5e", "#64748b", "#0891b2", "#dc2626", "#f59e0b",
] "#ec4899",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#84cc16",
"#f97316",
"#6366f1",
"#14b8a6",
"#f43f5e",
"#64748b",
"#0891b2",
"#dc2626",
];
export default function CategoriesPage() { export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData() const { data, isLoading, refresh } = useBankingData();
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null) const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set()) const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set(),
);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
color: "#22c55e", color: "#22c55e",
keywords: [] as string[], keywords: [] as string[],
parentId: null as string | null, parentId: null as string | null,
}) });
const [newKeyword, setNewKeyword] = useState("") const [newKeyword, setNewKeyword] = useState("");
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("");
// Organiser les catégories par parent // Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } = useMemo(() => { const { parentCategories, childrenByParent, orphanCategories } =
if (!data?.categories) return { parentCategories: [], childrenByParent: {}, orphanCategories: [] } useMemo(() => {
if (!data?.categories)
return {
parentCategories: [],
childrenByParent: {},
orphanCategories: [],
};
const parents = data.categories.filter((c) => c.parentId === null) const parents = data.categories.filter((c) => c.parentId === null);
const children: Record<string, Category[]> = {} const children: Record<string, Category[]> = {};
const orphans: Category[] = [] const orphans: Category[] = [];
// Grouper les enfants par parent // Grouper les enfants par parent
data.categories data.categories
.filter((c) => c.parentId !== null) .filter((c) => c.parentId !== null)
.forEach((child) => { .forEach((child) => {
const parentExists = parents.some((p) => p.id === child.parentId) const parentExists = parents.some((p) => p.id === child.parentId);
if (parentExists) { if (parentExists) {
if (!children[child.parentId!]) { if (!children[child.parentId!]) {
children[child.parentId!] = [] children[child.parentId!] = [];
} }
children[child.parentId!].push(child) children[child.parentId!].push(child);
} else { } else {
orphans.push(child) orphans.push(child);
} }
}) });
return { return {
parentCategories: parents, parentCategories: parents,
childrenByParent: children, childrenByParent: children,
orphanCategories: orphans, orphanCategories: orphans,
} };
}, [data?.categories]) }, [data?.categories]);
// Initialiser tous les parents comme ouverts // Initialiser tous les parents comme ouverts
useState(() => { useState(() => {
if (parentCategories.length > 0 && expandedParents.size === 0) { if (parentCategories.length > 0 && expandedParents.size === 0) {
setExpandedParents(new Set(parentCategories.map((p) => p.id))) setExpandedParents(new Set(parentCategories.map((p) => p.id)));
} }
}) });
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -82,62 +138,75 @@ export default function CategoriesPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" /> <RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main> </main>
</div> </div>
) );
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(amount) return new Intl.NumberFormat("fr-FR", {
} style: "currency",
currency: "EUR",
}).format(amount);
};
const getCategoryStats = (categoryId: string, includeChildren = false) => { const getCategoryStats = (categoryId: string, includeChildren = false) => {
let categoryIds = [categoryId] let categoryIds = [categoryId];
if (includeChildren && childrenByParent[categoryId]) { if (includeChildren && childrenByParent[categoryId]) {
categoryIds = [...categoryIds, ...childrenByParent[categoryId].map((c) => c.id)] categoryIds = [
...categoryIds,
...childrenByParent[categoryId].map((c) => c.id),
];
} }
const categoryTransactions = data.transactions.filter((t) => categoryIds.includes(t.categoryId || "")) const categoryTransactions = data.transactions.filter((t) =>
const total = categoryTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0) categoryIds.includes(t.categoryId || ""),
const count = categoryTransactions.length );
return { total, count } const total = categoryTransactions.reduce(
} (sum, t) => sum + Math.abs(t.amount),
0,
);
const count = categoryTransactions.length;
return { total, count };
};
const toggleExpanded = (parentId: string) => { const toggleExpanded = (parentId: string) => {
const newExpanded = new Set(expandedParents) const newExpanded = new Set(expandedParents);
if (newExpanded.has(parentId)) { if (newExpanded.has(parentId)) {
newExpanded.delete(parentId) newExpanded.delete(parentId);
} else { } else {
newExpanded.add(parentId) newExpanded.add(parentId);
}
setExpandedParents(newExpanded)
} }
setExpandedParents(newExpanded);
};
const expandAll = () => { const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p) => p.id))) setExpandedParents(new Set(parentCategories.map((p) => p.id)));
} };
const collapseAll = () => { const collapseAll = () => {
setExpandedParents(new Set()) setExpandedParents(new Set());
} };
const allExpanded = parentCategories.length > 0 && expandedParents.size === parentCategories.length const allExpanded =
parentCategories.length > 0 &&
expandedParents.size === parentCategories.length;
const handleNewCategory = (parentId: string | null = null) => { const handleNewCategory = (parentId: string | null = null) => {
setEditingCategory(null) setEditingCategory(null);
setFormData({ name: "", color: "#22c55e", keywords: [], parentId }) setFormData({ name: "", color: "#22c55e", keywords: [], parentId });
setIsDialogOpen(true) setIsDialogOpen(true);
} };
const handleEdit = (category: Category) => { const handleEdit = (category: Category) => {
setEditingCategory(category) setEditingCategory(category);
setFormData({ setFormData({
name: category.name, name: category.name,
color: category.color, color: category.color,
keywords: [...category.keywords], keywords: [...category.keywords],
parentId: category.parentId, parentId: category.parentId,
}) });
setIsDialogOpen(true) setIsDialogOpen(true);
} };
const handleSave = async () => { const handleSave = async () => {
try { try {
@@ -148,7 +217,7 @@ export default function CategoriesPage() {
color: formData.color, color: formData.color,
keywords: formData.keywords, keywords: formData.keywords,
parentId: formData.parentId, parentId: formData.parentId,
}) });
} else { } else {
await addCategory({ await addCategory({
name: formData.name, name: formData.name,
@@ -156,78 +225,88 @@ export default function CategoriesPage() {
keywords: formData.keywords, keywords: formData.keywords,
icon: "tag", icon: "tag",
parentId: formData.parentId, parentId: formData.parentId,
}) });
} }
refresh() refresh();
setIsDialogOpen(false) setIsDialogOpen(false);
} catch (error) { } catch (error) {
console.error("Error saving category:", error) console.error("Error saving category:", error);
alert("Erreur lors de la sauvegarde de la catégorie") alert("Erreur lors de la sauvegarde de la catégorie");
}
} }
};
const handleDelete = async (categoryId: string) => { const handleDelete = async (categoryId: string) => {
const hasChildren = childrenByParent[categoryId]?.length > 0 const hasChildren = childrenByParent[categoryId]?.length > 0;
const message = hasChildren const message = hasChildren
? "Cette catégorie a des sous-catégories. Supprimer quand même ?" ? "Cette catégorie a des sous-catégories. Supprimer quand même ?"
: "Supprimer cette catégorie ?" : "Supprimer cette catégorie ?";
if (!confirm(message)) return if (!confirm(message)) return;
try { try {
await deleteCategory(categoryId) await deleteCategory(categoryId);
refresh() refresh();
} catch (error) { } catch (error) {
console.error("Error deleting category:", error) console.error("Error deleting category:", error);
alert("Erreur lors de la suppression de la catégorie") alert("Erreur lors de la suppression de la catégorie");
}
} }
};
const addKeyword = () => { const addKeyword = () => {
if (newKeyword.trim() && !formData.keywords.includes(newKeyword.trim().toLowerCase())) { if (
newKeyword.trim() &&
!formData.keywords.includes(newKeyword.trim().toLowerCase())
) {
setFormData({ setFormData({
...formData, ...formData,
keywords: [...formData.keywords, newKeyword.trim().toLowerCase()], keywords: [...formData.keywords, newKeyword.trim().toLowerCase()],
}) });
setNewKeyword("") setNewKeyword("");
}
} }
};
const removeKeyword = (keyword: string) => { const removeKeyword = (keyword: string) => {
setFormData({ setFormData({
...formData, ...formData,
keywords: formData.keywords.filter((k) => k !== keyword), keywords: formData.keywords.filter((k) => k !== keyword),
}) });
} };
const reApplyAutoCategories = async () => { const reApplyAutoCategories = async () => {
if (!confirm("Recatégoriser automatiquement les transactions non catégorisées ?")) return if (
!confirm(
"Recatégoriser automatiquement les transactions non catégorisées ?",
)
)
return;
try { try {
const { updateTransaction } = await import("@/lib/store-db") const { updateTransaction } = await import("@/lib/store-db");
const uncategorized = data.transactions.filter((t) => !t.categoryId) const uncategorized = data.transactions.filter((t) => !t.categoryId);
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
data.categories data.categories,
) );
if (categoryId) { if (categoryId) {
await updateTransaction({ ...transaction, categoryId }) await updateTransaction({ ...transaction, categoryId });
} }
} }
refresh() refresh();
} catch (error) { } catch (error) {
console.error("Error re-categorizing:", error) console.error("Error re-categorizing:", error);
alert("Erreur lors de la recatégorisation") alert("Erreur lors de la recatégorisation");
}
} }
};
const uncategorizedCount = data.transactions.filter((t) => !t.categoryId).length const uncategorizedCount = data.transactions.filter(
(t) => !t.categoryId,
).length;
// Composant pour une carte de catégorie enfant // Composant pour une carte de catégorie enfant
const ChildCategoryCard = ({ category }: { category: Category }) => { const ChildCategoryCard = ({ category }: { category: Category }) => {
const stats = getCategoryStats(category.id) const stats = getCategoryStats(category.id);
return ( return (
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group"> <div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
@@ -236,21 +315,34 @@ export default function CategoriesPage() {
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0" className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${category.color}20` }} style={{ backgroundColor: `${category.color}20` }}
> >
<CategoryIcon icon={category.icon} color={category.color} size={12} /> <CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
</div> </div>
<span className="text-sm truncate">{category.name}</span> <span className="text-sm truncate">{category.name}</span>
<span className="text-sm text-muted-foreground shrink-0"> <span className="text-sm text-muted-foreground shrink-0">
{stats.count} opération{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)} {stats.count} opération{stats.count > 1 ? "s" : ""} {" "}
{formatCurrency(stats.total)}
</span> </span>
{category.keywords.length > 0 && ( {category.keywords.length > 0 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0"> <Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
>
{category.keywords.length} {category.keywords.length}
</Badge> </Badge>
)} )}
</div> </div>
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEdit(category)}> <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleEdit(category)}
>
<Pencil className="w-3 h-3" /> <Pencil className="w-3 h-3" />
</Button> </Button>
<Button <Button
@@ -263,8 +355,8 @@ export default function CategoriesPage() {
</Button> </Button>
</div> </div>
</div> </div>
) );
} };
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
@@ -277,7 +369,8 @@ export default function CategoriesPage() {
<h1 className="text-2xl font-bold text-foreground">Catégories</h1> <h1 className="text-2xl font-bold text-foreground">Catégories</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{parentCategories.length} catégories principales {" "} {parentCategories.length} catégories principales {" "}
{data.categories.length - parentCategories.length} sous-catégories {data.categories.length - parentCategories.length}{" "}
sous-catégories
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -326,37 +419,51 @@ export default function CategoriesPage() {
<div className="space-y-1"> <div className="space-y-1">
{parentCategories {parentCategories
.filter((parent) => { .filter((parent) => {
if (!searchQuery.trim()) return true if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase() const query = searchQuery.toLowerCase();
// Afficher si le parent matche // Afficher si le parent matche
if (parent.name.toLowerCase().includes(query)) return true if (parent.name.toLowerCase().includes(query)) return true;
if (parent.keywords.some((k) => k.toLowerCase().includes(query))) return true if (
parent.keywords.some((k) => k.toLowerCase().includes(query))
)
return true;
// Ou si un enfant matche // Ou si un enfant matche
const children = childrenByParent[parent.id] || [] const children = childrenByParent[parent.id] || [];
return children.some( return children.some(
(c) => (c) =>
c.name.toLowerCase().includes(query) || c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query)) c.keywords.some((k) => k.toLowerCase().includes(query)),
) );
}) })
.map((parent) => { .map((parent) => {
const allChildren = childrenByParent[parent.id] || [] const allChildren = childrenByParent[parent.id] || [];
// Filtrer les enfants aussi si recherche active // Filtrer les enfants aussi si recherche active
const children = searchQuery.trim() const children = searchQuery.trim()
? allChildren.filter( ? allChildren.filter(
(c) => (c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.name
c.keywords.some((k) => k.toLowerCase().includes(searchQuery.toLowerCase())) || .toLowerCase()
.includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase()),
) ||
// Garder tous les enfants si le parent matche // Garder tous les enfants si le parent matche
parent.name.toLowerCase().includes(searchQuery.toLowerCase()) parent.name
.toLowerCase()
.includes(searchQuery.toLowerCase()),
) )
: allChildren : allChildren;
const stats = getCategoryStats(parent.id, true) const stats = getCategoryStats(parent.id, true);
const isExpanded = expandedParents.has(parent.id) || (searchQuery.trim() !== "" && children.length > 0) const isExpanded =
expandedParents.has(parent.id) ||
(searchQuery.trim() !== "" && children.length > 0);
return ( return (
<div key={parent.id} className="border rounded-lg bg-card"> <div key={parent.id} className="border rounded-lg bg-card">
<Collapsible open={isExpanded} onOpenChange={() => toggleExpanded(parent.id)}> <Collapsible
open={isExpanded}
onOpenChange={() => toggleExpanded(parent.id)}
>
<div className="flex items-center justify-between px-3 py-2"> <div className="flex items-center justify-between px-3 py-2">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0"> <button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
@@ -369,11 +476,19 @@ export default function CategoriesPage() {
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0" className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }} style={{ backgroundColor: `${parent.color}20` }}
> >
<CategoryIcon icon={parent.icon} color={parent.color} size={14} /> <CategoryIcon
icon={parent.icon}
color={parent.color}
size={14}
/>
</div> </div>
<span className="font-medium text-sm truncate">{parent.name}</span> <span className="font-medium text-sm truncate">
{parent.name}
</span>
<span className="text-sm text-muted-foreground shrink-0"> <span className="text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)} {children.length} {stats.count} opération
{stats.count > 1 ? "s" : ""} {" "}
{formatCurrency(stats.total)}
</span> </span>
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
@@ -384,20 +499,26 @@ export default function CategoriesPage() {
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
handleNewCategory(parent.id) handleNewCategory(parent.id);
}} }}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7"> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(parent)}> <DropdownMenuItem
onClick={() => handleEdit(parent)}
>
<Pencil className="w-4 h-4 mr-2" /> <Pencil className="w-4 h-4 mr-2" />
Modifier Modifier
</DropdownMenuItem> </DropdownMenuItem>
@@ -417,7 +538,10 @@ export default function CategoriesPage() {
{children.length > 0 ? ( {children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5"> <div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{children.map((child) => ( {children.map((child) => (
<ChildCategoryCard key={child.id} category={child} /> <ChildCategoryCard
key={child.id}
category={child}
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -428,7 +552,7 @@ export default function CategoriesPage() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
) );
})} })}
{/* Catégories orphelines (sans parent valide) */} {/* Catégories orphelines (sans parent valide) */}
@@ -465,14 +589,19 @@ export default function CategoriesPage() {
<Select <Select
value={formData.parentId || "none"} value={formData.parentId || "none"}
onValueChange={(value) => onValueChange={(value) =>
setFormData({ ...formData, parentId: value === "none" ? null : value }) setFormData({
...formData,
parentId: value === "none" ? null : value,
})
} }
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Aucune (catégorie principale)" /> <SelectValue placeholder="Aucune (catégorie principale)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">Aucune (catégorie principale)</SelectItem> <SelectItem value="none">
Aucune (catégorie principale)
</SelectItem>
{parentCategories {parentCategories
.filter((p) => p.id !== editingCategory?.id) .filter((p) => p.id !== editingCategory?.id)
.map((parent) => ( .map((parent) => (
@@ -495,7 +624,9 @@ export default function CategoriesPage() {
<Label>Nom</Label> <Label>Nom</Label>
<Input <Input
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Ex: Alimentation" placeholder="Ex: Alimentation"
/> />
</div> </div>
@@ -510,7 +641,8 @@ export default function CategoriesPage() {
onClick={() => setFormData({ ...formData, color })} onClick={() => setFormData({ ...formData, color })}
className={cn( className={cn(
"w-8 h-8 rounded-full transition-transform", "w-8 h-8 rounded-full transition-transform",
formData.color === color && "ring-2 ring-offset-2 ring-primary scale-110" formData.color === color &&
"ring-2 ring-offset-2 ring-primary scale-110",
)} )}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
/> />
@@ -526,7 +658,9 @@ export default function CategoriesPage() {
value={newKeyword} value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)} onChange={(e) => setNewKeyword(e.target.value)}
placeholder="Ajouter un mot-clé" placeholder="Ajouter un mot-clé"
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addKeyword())} onKeyDown={(e) =>
e.key === "Enter" && (e.preventDefault(), addKeyword())
}
/> />
<Button type="button" onClick={addKeyword} size="icon"> <Button type="button" onClick={addKeyword} size="icon">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -557,5 +691,5 @@ export default function CategoriesPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@@ -1,15 +1,31 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" Dialog,
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Plus, Plus,
MoreVertical, MoreVertical,
@@ -21,10 +37,15 @@ import {
ChevronDown, ChevronDown,
Building2, Building2,
RefreshCw, RefreshCw,
} from "lucide-react" } from "lucide-react";
import { addFolder, updateFolder, deleteFolder, updateAccount } from "@/lib/store-db" import {
import type { Folder as FolderType, Account } from "@/lib/types" addFolder,
import { cn } from "@/lib/utils" updateFolder,
deleteFolder,
updateAccount,
} from "@/lib/store-db";
import type { Folder as FolderType, Account } from "@/lib/types";
import { cn } from "@/lib/utils";
const folderColors = [ const folderColors = [
{ value: "#6366f1", label: "Indigo" }, { value: "#6366f1", label: "Indigo" },
@@ -33,17 +54,17 @@ const folderColors = [
{ value: "#ec4899", label: "Rose" }, { value: "#ec4899", label: "Rose" },
{ value: "#3b82f6", label: "Bleu" }, { value: "#3b82f6", label: "Bleu" },
{ value: "#ef4444", label: "Rouge" }, { value: "#ef4444", label: "Rouge" },
] ];
interface FolderTreeItemProps { interface FolderTreeItemProps {
folder: FolderType folder: FolderType;
accounts: Account[] accounts: Account[];
allFolders: FolderType[] allFolders: FolderType[];
level: number level: number;
onEdit: (folder: FolderType) => void onEdit: (folder: FolderType) => void;
onDelete: (folderId: string) => void onDelete: (folderId: string) => void;
onEditAccount: (account: Account) => void onEditAccount: (account: Account) => void;
formatCurrency: (amount: number) => string formatCurrency: (amount: number) => string;
} }
function FolderTreeItem({ function FolderTreeItem({
@@ -56,20 +77,27 @@ function FolderTreeItem({
onEditAccount, onEditAccount,
formatCurrency, formatCurrency,
}: FolderTreeItemProps) { }: FolderTreeItemProps) {
const [isExpanded, setIsExpanded] = useState(true) const [isExpanded, setIsExpanded] = useState(true);
// Pour le dossier "Mes Comptes" (folder-root), inclure aussi les comptes sans dossier // Pour le dossier "Mes Comptes" (folder-root), inclure aussi les comptes sans dossier
const folderAccounts = accounts.filter((a) => const folderAccounts = accounts.filter(
a.folderId === folder.id || (folder.id === "folder-root" && a.folderId === null) (a) =>
) a.folderId === folder.id ||
const childFolders = allFolders.filter((f) => f.parentId === folder.id) (folder.id === "folder-root" && a.folderId === null),
const hasChildren = childFolders.length > 0 || folderAccounts.length > 0 );
const childFolders = allFolders.filter((f) => f.parentId === folder.id);
const hasChildren = childFolders.length > 0 || folderAccounts.length > 0;
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0) const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0);
return ( return (
<div> <div>
<div className={cn("flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group", level > 0 && "ml-6")}> <div
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
level > 0 && "ml-6",
)}
>
<button <button
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-muted rounded" className="p-1 hover:bg-muted rounded"
@@ -101,7 +129,10 @@ function FolderTreeItem({
{folderAccounts.length > 0 && ( {folderAccounts.length > 0 && (
<span <span
className={cn("text-sm font-semibold tabular-nums", folderTotal >= 0 ? "text-emerald-600" : "text-red-600")} className={cn(
"text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
)}
> >
{formatCurrency(folderTotal)} {formatCurrency(folderTotal)}
</span> </span>
@@ -109,7 +140,11 @@ function FolderTreeItem({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100"> <Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -119,7 +154,10 @@ function FolderTreeItem({
Modifier Modifier
</DropdownMenuItem> </DropdownMenuItem>
{folder.id !== "folder-root" && ( {folder.id !== "folder-root" && (
<DropdownMenuItem onClick={() => onDelete(folder.id)} className="text-red-600"> <DropdownMenuItem
onClick={() => onDelete(folder.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Supprimer Supprimer
</DropdownMenuItem> </DropdownMenuItem>
@@ -131,10 +169,21 @@ function FolderTreeItem({
{isExpanded && ( {isExpanded && (
<div> <div>
{folderAccounts.map((account) => ( {folderAccounts.map((account) => (
<div key={account.id} className={cn("flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group", "ml-12")}> <div
key={account.id}
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
"ml-12",
)}
>
<Building2 className="w-4 h-4 text-muted-foreground" /> <Building2 className="w-4 h-4 text-muted-foreground" />
<span className="flex-1 text-sm">{account.name}</span> <span className="flex-1 text-sm">{account.name}</span>
<span className={cn("text-sm tabular-nums", account.balance >= 0 ? "text-emerald-600" : "text-red-600")}> <span
className={cn(
"text-sm tabular-nums",
account.balance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(account.balance)} {formatCurrency(account.balance)}
</span> </span>
<Button <Button
@@ -164,7 +213,7 @@ function FolderTreeItem({
</div> </div>
)} )}
</div> </div>
) );
} }
const accountTypeLabels = { const accountTypeLabels = {
@@ -172,26 +221,26 @@ const accountTypeLabels = {
SAVINGS: "Épargne", SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit", CREDIT_CARD: "Carte de crédit",
OTHER: "Autre", OTHER: "Autre",
} };
export default function FoldersPage() { export default function FoldersPage() {
const { data, isLoading, refresh } = useBankingData() const { data, isLoading, refresh } = useBankingData();
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingFolder, setEditingFolder] = useState<FolderType | null>(null) const [editingFolder, setEditingFolder] = useState<FolderType | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
parentId: "folder-root" as string | null, parentId: "folder-root" as string | null,
color: "#6366f1", color: "#6366f1",
}) });
// Account editing state // Account editing state
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false) const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<Account | null>(null) const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [accountFormData, setAccountFormData] = useState({ const [accountFormData, setAccountFormData] = useState({
name: "", name: "",
type: "CHECKING" as Account["type"], type: "CHECKING" as Account["type"],
folderId: "folder-root", folderId: "folder-root",
}) });
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -201,36 +250,37 @@ export default function FoldersPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" /> <RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main> </main>
</div> </div>
) );
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(amount) }).format(amount);
} };
const rootFolders = data.folders.filter((f) => f.parentId === null) const rootFolders = data.folders.filter((f) => f.parentId === null);
const handleNewFolder = () => { const handleNewFolder = () => {
setEditingFolder(null) setEditingFolder(null);
setFormData({ name: "", parentId: "folder-root", color: "#6366f1" }) setFormData({ name: "", parentId: "folder-root", color: "#6366f1" });
setIsDialogOpen(true) setIsDialogOpen(true);
} };
const handleEdit = (folder: FolderType) => { const handleEdit = (folder: FolderType) => {
setEditingFolder(folder) setEditingFolder(folder);
setFormData({ setFormData({
name: folder.name, name: folder.name,
parentId: folder.parentId || "folder-root", parentId: folder.parentId || "folder-root",
color: folder.color, color: folder.color,
}) });
setIsDialogOpen(true) setIsDialogOpen(true);
} };
const handleSave = async () => { const handleSave = async () => {
const parentId = formData.parentId === "folder-root" ? null : formData.parentId const parentId =
formData.parentId === "folder-root" ? null : formData.parentId;
try { try {
if (editingFolder) { if (editingFolder) {
@@ -239,63 +289,71 @@ export default function FoldersPage() {
name: formData.name, name: formData.name,
parentId, parentId,
color: formData.color, color: formData.color,
}) });
} else { } else {
await addFolder({ await addFolder({
name: formData.name, name: formData.name,
parentId, parentId,
color: formData.color, color: formData.color,
icon: "folder", icon: "folder",
}) });
} }
refresh() refresh();
setIsDialogOpen(false) setIsDialogOpen(false);
} catch (error) { } catch (error) {
console.error("Error saving folder:", error) console.error("Error saving folder:", error);
alert("Erreur lors de la sauvegarde du dossier") alert("Erreur lors de la sauvegarde du dossier");
}
} }
};
const handleDelete = async (folderId: string) => { const handleDelete = async (folderId: string) => {
if (!confirm("Supprimer ce dossier ? Les comptes seront déplacés à la racine.")) return if (
!confirm(
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
)
)
return;
try { try {
await deleteFolder(folderId) await deleteFolder(folderId);
refresh() refresh();
} catch (error) { } catch (error) {
console.error("Error deleting folder:", error) console.error("Error deleting folder:", error);
alert("Erreur lors de la suppression du dossier") alert("Erreur lors de la suppression du dossier");
}
} }
};
const handleEditAccount = (account: Account) => { const handleEditAccount = (account: Account) => {
setEditingAccount(account) setEditingAccount(account);
setAccountFormData({ setAccountFormData({
name: account.name, name: account.name,
type: account.type, type: account.type,
folderId: account.folderId || "folder-root", folderId: account.folderId || "folder-root",
}) });
setIsAccountDialogOpen(true) setIsAccountDialogOpen(true);
} };
const handleSaveAccount = async () => { const handleSaveAccount = async () => {
if (!editingAccount) return if (!editingAccount) return;
try { try {
await updateAccount({ await updateAccount({
...editingAccount, ...editingAccount,
name: accountFormData.name, name: accountFormData.name,
type: accountFormData.type, type: accountFormData.type,
folderId: accountFormData.folderId === "folder-root" ? null : accountFormData.folderId, folderId:
}) accountFormData.folderId === "folder-root"
refresh() ? null
setIsAccountDialogOpen(false) : accountFormData.folderId,
setEditingAccount(null) });
refresh();
setIsAccountDialogOpen(false);
setEditingAccount(null);
} catch (error) { } catch (error) {
console.error("Error updating account:", error) console.error("Error updating account:", error);
alert("Erreur lors de la mise à jour du compte") alert("Erreur lors de la mise à jour du compte");
}
} }
};
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
@@ -304,8 +362,12 @@ export default function FoldersPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Organisation</h1> <h1 className="text-2xl font-bold text-foreground">
<p className="text-muted-foreground">Organisez vos comptes en dossiers</p> Organisation
</h1>
<p className="text-muted-foreground">
Organisez vos comptes en dossiers
</p>
</div> </div>
<Button onClick={handleNewFolder}> <Button onClick={handleNewFolder}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
@@ -341,14 +403,18 @@ export default function FoldersPage() {
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}</DialogTitle> <DialogTitle>
{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Nom du dossier</Label> <Label>Nom du dossier</Label>
<Input <Input
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Ex: Comptes personnels" placeholder="Ex: Comptes personnels"
/> />
</div> </div>
@@ -356,7 +422,12 @@ export default function FoldersPage() {
<Label>Dossier parent</Label> <Label>Dossier parent</Label>
<Select <Select
value={formData.parentId || "root"} value={formData.parentId || "root"}
onValueChange={(v) => setFormData({ ...formData, parentId: v === "root" ? null : v })} onValueChange={(v) =>
setFormData({
...formData,
parentId: v === "root" ? null : v,
})
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -382,7 +453,8 @@ export default function FoldersPage() {
onClick={() => setFormData({ ...formData, color: value })} onClick={() => setFormData({ ...formData, color: value })}
className={cn( className={cn(
"w-8 h-8 rounded-full transition-transform", "w-8 h-8 rounded-full transition-transform",
formData.color === value && "ring-2 ring-offset-2 ring-primary scale-110", formData.color === value &&
"ring-2 ring-offset-2 ring-primary scale-110",
)} )}
style={{ backgroundColor: value }} style={{ backgroundColor: value }}
/> />
@@ -411,14 +483,24 @@ export default function FoldersPage() {
<Label>Nom du compte</Label> <Label>Nom du compte</Label>
<Input <Input
value={accountFormData.name} value={accountFormData.name}
onChange={(e) => setAccountFormData({ ...accountFormData, name: e.target.value })} onChange={(e) =>
setAccountFormData({
...accountFormData,
name: e.target.value,
})
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type de compte</Label> <Label>Type de compte</Label>
<Select <Select
value={accountFormData.type} value={accountFormData.type}
onValueChange={(v) => setAccountFormData({ ...accountFormData, type: v as Account["type"] })} onValueChange={(v) =>
setAccountFormData({
...accountFormData,
type: v as Account["type"],
})
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -436,7 +518,9 @@ export default function FoldersPage() {
<Label>Dossier</Label> <Label>Dossier</Label>
<Select <Select
value={accountFormData.folderId} value={accountFormData.folderId}
onValueChange={(v) => setAccountFormData({ ...accountFormData, folderId: v })} onValueChange={(v) =>
setAccountFormData({ ...accountFormData, folderId: v })
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -451,7 +535,10 @@ export default function FoldersPage() {
</Select> </Select>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsAccountDialogOpen(false)}> <Button
variant="outline"
onClick={() => setIsAccountDialogOpen(false)}
>
Annuler Annuler
</Button> </Button>
<Button onClick={handleSaveAccount}>Enregistrer</Button> <Button onClick={handleSaveAccount}>Enregistrer</Button>
@@ -460,5 +547,5 @@ export default function FoldersPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@@ -1,5 +1,5 @@
@import 'tailwindcss'; @import "tailwindcss";
@import 'tw-animate-css'; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -75,8 +75,8 @@
} }
@theme inline { @theme inline {
--font-sans: 'Geist', 'Geist Fallback'; --font-sans: "Geist", "Geist Fallback";
--font-mono: 'Geist Mono', 'Geist Mono Fallback'; --font-mono: "Geist Mono", "Geist Mono Fallback";
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);

View File

@@ -1,25 +1,26 @@
import type React from "react" import type React from "react";
import type { Metadata } from "next" import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google" import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css" import "./globals.css";
const _geist = Geist({ subsets: ["latin"] }) const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] }) const _geistMono = Geist_Mono({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "FinTrack - Gestion de compte bancaire", title: "FinTrack - Gestion de compte bancaire",
description: "Application de gestion personnelle de comptes bancaires avec import OFX", description:
generator: 'v0.app' "Application de gestion personnelle de comptes bancaires avec import OFX",
} generator: "v0.app",
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="fr"> <html lang="fr">
<body className="font-sans antialiased">{children}</body> <body className="font-sans antialiased">{children}</body>
</html> </html>
) );
} }

View File

@@ -1,17 +1,17 @@
"use client" "use client";
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar";
import { OverviewCards } from "@/components/dashboard/overview-cards" import { OverviewCards } from "@/components/dashboard/overview-cards";
import { RecentTransactions } from "@/components/dashboard/recent-transactions" import { RecentTransactions } from "@/components/dashboard/recent-transactions";
import { AccountsSummary } from "@/components/dashboard/accounts-summary" import { AccountsSummary } from "@/components/dashboard/accounts-summary";
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown" import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog" import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Upload, RefreshCw } from "lucide-react" import { Upload, RefreshCw } from "lucide-react";
export default function DashboardPage() { export default function DashboardPage() {
const { data, isLoading, refresh } = useBankingData() const { data, isLoading, refresh } = useBankingData();
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -21,7 +21,7 @@ export default function DashboardPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" /> <RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main> </main>
</div> </div>
) );
} }
return ( return (
@@ -31,8 +31,12 @@ export default function DashboardPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Tableau de bord</h1> <h1 className="text-2xl font-bold text-foreground">
<p className="text-muted-foreground">Vue d'ensemble de vos finances</p> Tableau de bord
</h1>
<p className="text-muted-foreground">
Vue d'ensemble de vos finances
</p>
</div> </div>
<OFXImportDialog onImportComplete={refresh}> <OFXImportDialog onImportComplete={refresh}>
<Button> <Button>
@@ -54,5 +58,5 @@ export default function DashboardPage() {
</div> </div>
</main> </main>
</div> </div>
) );
} }

View File

@@ -1,10 +1,16 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -15,13 +21,21 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog";
import { Download, Trash2, Upload, RefreshCw, Database, FileJson, Tags } from "lucide-react" import {
import type { BankingData } from "@/lib/types" Download,
Trash2,
Upload,
RefreshCw,
Database,
FileJson,
Tags,
} from "lucide-react";
import type { BankingData } from "@/lib/types";
export default function SettingsPage() { export default function SettingsPage() {
const { data, isLoading, refresh, update } = useBankingData() const { data, isLoading, refresh, update } = useBankingData();
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false);
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -31,72 +45,80 @@ export default function SettingsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" /> <RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main> </main>
</div> </div>
) );
} }
const exportData = () => { const exportData = () => {
const dataStr = JSON.stringify(data, null, 2) const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], { type: "application/json" }) const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob);
const a = document.createElement("a") const a = document.createElement("a");
a.href = url a.href = url;
a.download = `fintrack-backup-${new Date().toISOString().split("T")[0]}.json` a.download = `fintrack-backup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a) document.body.appendChild(a);
a.click() a.click();
document.body.removeChild(a) document.body.removeChild(a);
URL.revokeObjectURL(url) URL.revokeObjectURL(url);
} };
const importData = () => { const importData = () => {
const input = document.createElement("input") const input = document.createElement("input");
input.type = "file" input.type = "file";
input.accept = ".json" input.accept = ".json";
input.onchange = async (e) => { input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0] const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return if (!file) return;
setImporting(true) setImporting(true);
try { try {
const content = await file.text() const content = await file.text();
const importedData = JSON.parse(content) as BankingData const importedData = JSON.parse(content) as BankingData;
// Validate structure // Validate structure
if (!importedData.accounts || !importedData.transactions || !importedData.categories || !importedData.folders) { if (
alert("Format de fichier invalide") !importedData.accounts ||
return !importedData.transactions ||
!importedData.categories ||
!importedData.folders
) {
alert("Format de fichier invalide");
return;
} }
update(importedData) update(importedData);
alert("Données importées avec succès") alert("Données importées avec succès");
} catch (error) { } catch (error) {
alert("Erreur lors de l'import") alert("Erreur lors de l'import");
} finally { } finally {
setImporting(false) setImporting(false);
}
}
input.click()
} }
};
input.click();
};
const resetData = () => { const resetData = () => {
localStorage.removeItem("banking-app-data") localStorage.removeItem("banking-app-data");
window.location.reload() window.location.reload();
} };
const clearAllCategories = async () => { const clearAllCategories = async () => {
try { try {
const response = await fetch("/api/banking/transactions/clear-categories", { const response = await fetch(
"/api/banking/transactions/clear-categories",
{
method: "POST", method: "POST",
}) },
if (!response.ok) throw new Error("Erreur") );
refresh() if (!response.ok) throw new Error("Erreur");
alert("Catégories supprimées de toutes les opérations") refresh();
alert("Catégories supprimées de toutes les opérations");
} catch (error) { } catch (error) {
console.error(error) console.error(error);
alert("Erreur lors de la suppression des catégories") alert("Erreur lors de la suppression des catégories");
}
} }
};
const categorizedCount = data.transactions.filter((t) => t.categoryId).length const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
@@ -105,7 +127,9 @@ export default function SettingsPage() {
<div className="p-6 space-y-6 max-w-2xl"> <div className="p-6 space-y-6 max-w-2xl">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1> <h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
<p className="text-muted-foreground">Gérez vos données et préférences</p> <p className="text-muted-foreground">
Gérez vos données et préférences
</p>
</div> </div>
<Card> <Card>
@@ -114,25 +138,37 @@ export default function SettingsPage() {
<Database className="w-5 h-5" /> <Database className="w-5 h-5" />
Données Données
</CardTitle> </CardTitle>
<CardDescription>Exportez ou importez vos données pour les sauvegarder ou les transférer</CardDescription> <CardDescription>
Exportez ou importez vos données pour les sauvegarder ou les
transférer
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div> <div>
<p className="font-medium">Statistiques</p> <p className="font-medium">Statistiques</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{data.accounts.length} comptes, {data.transactions.length} transactions, {data.categories.length}{" "} {data.accounts.length} comptes, {data.transactions.length}{" "}
catégories transactions, {data.categories.length} catégories
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={exportData} variant="outline" className="flex-1 bg-transparent"> <Button
onClick={exportData}
variant="outline"
className="flex-1 bg-transparent"
>
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
Exporter (JSON) Exporter (JSON)
</Button> </Button>
<Button onClick={importData} variant="outline" className="flex-1 bg-transparent" disabled={importing}> <Button
onClick={importData}
variant="outline"
className="flex-1 bg-transparent"
disabled={importing}
>
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
{importing ? "Import..." : "Importer"} {importing ? "Import..." : "Importer"}
</Button> </Button>
@@ -146,31 +182,45 @@ export default function SettingsPage() {
<Trash2 className="w-5 h-5" /> <Trash2 className="w-5 h-5" />
Zone dangereuse Zone dangereuse
</CardTitle> </CardTitle>
<CardDescription>Actions irréversibles - procédez avec prudence</CardDescription> <CardDescription>
Actions irréversibles - procédez avec prudence
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Supprimer catégories des opérations */} {/* Supprimer catégories des opérations */}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50"> <Button
variant="outline"
className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50"
>
<Tags className="w-4 h-4 mr-2" /> <Tags className="w-4 h-4 mr-2" />
Supprimer les catégories des opérations Supprimer les catégories des opérations
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">
{categorizedCount} opération{categorizedCount > 1 ? "s" : ""} catégorisée{categorizedCount > 1 ? "s" : ""} {categorizedCount} opération
{categorizedCount > 1 ? "s" : ""} catégorisée
{categorizedCount > 1 ? "s" : ""}
</span> </span>
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Supprimer toutes les catégories ?</AlertDialogTitle> <AlertDialogTitle>
Supprimer toutes les catégories ?
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Cette action va retirer la catégorie de {categorizedCount} opération{categorizedCount > 1 ? "s" : ""}. Cette action va retirer la catégorie de {categorizedCount}{" "}
Les catégories elles-mêmes ne seront pas supprimées, seulement leur affectation aux opérations. opération{categorizedCount > 1 ? "s" : ""}. Les catégories
elles-mêmes ne seront pas supprimées, seulement leur
affectation aux opérations.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel> <AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={clearAllCategories} className="bg-orange-600 hover:bg-orange-700"> <AlertDialogAction
onClick={clearAllCategories}
className="bg-orange-600 hover:bg-orange-700"
>
Supprimer les affectations Supprimer les affectations
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -180,7 +230,10 @@ export default function SettingsPage() {
{/* Réinitialiser toutes les données */} {/* Réinitialiser toutes les données */}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full justify-start"> <Button
variant="destructive"
className="w-full justify-start"
>
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Réinitialiser toutes les données Réinitialiser toutes les données
</Button> </Button>
@@ -189,13 +242,17 @@ export default function SettingsPage() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle> <AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Cette action supprimera définitivement tous vos comptes, transactions, catégories et dossiers. Cette action supprimera définitivement tous vos comptes,
Cette action est irréversible. transactions, catégories et dossiers. Cette action est
irréversible.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel> <AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={resetData} className="bg-red-600 hover:bg-red-700"> <AlertDialogAction
onClick={resetData}
className="bg-red-600 hover:bg-red-700"
>
Supprimer tout Supprimer tout
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -210,17 +267,21 @@ export default function SettingsPage() {
<FileJson className="w-5 h-5" /> <FileJson className="w-5 h-5" />
Format OFX Format OFX
</CardTitle> </CardTitle>
<CardDescription>Informations sur l'import de fichiers</CardDescription> <CardDescription>
Informations sur l'import de fichiers
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="prose prose-sm text-muted-foreground"> <div className="prose prose-sm text-muted-foreground">
<p> <p>
L'application accepte les fichiers au format OFX (Open Financial Exchange) ou QFX. Ces fichiers sont L'application accepte les fichiers au format OFX (Open
généralement disponibles depuis l'espace client de votre banque. Financial Exchange) ou QFX. Ces fichiers sont généralement
disponibles depuis l'espace client de votre banque.
</p> </p>
<p className="mt-2"> <p className="mt-2">
Lors de l'import, les transactions sont automatiquement catégorisées selon les mots-clés définis. Les Lors de l'import, les transactions sont automatiquement
doublons sont détectés et ignorés automatiquement. catégorisées selon les mots-clés définis. Les doublons sont
détectés et ignorés automatiquement.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -228,5 +289,5 @@ export default function SettingsPage() {
</div> </div>
</main> </main>
</div> </div>
) );
} }

View File

@@ -1,12 +1,18 @@
"use client" "use client";
import { useState, useMemo } from "react" import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import {
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react" Select,
import { CategoryIcon } from "@/components/ui/category-icon" SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react";
import { CategoryIcon } from "@/components/ui/category-icon";
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -21,111 +27,130 @@ import {
LineChart, LineChart,
Line, Line,
Legend, Legend,
} from "recharts" } from "recharts";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
type Period = "3months" | "6months" | "12months" | "all" type Period = "3months" | "6months" | "12months" | "all";
export default function StatisticsPage() { export default function StatisticsPage() {
const { data, isLoading } = useBankingData() const { data, isLoading } = useBankingData();
const [period, setPeriod] = useState<Period>("6months") const [period, setPeriod] = useState<Period>("6months");
const [selectedAccount, setSelectedAccount] = useState<string>("all") const [selectedAccount, setSelectedAccount] = useState<string>("all");
const stats = useMemo(() => { const stats = useMemo(() => {
if (!data) return null if (!data) return null;
const now = new Date() const now = new Date();
let startDate: Date let startDate: Date;
switch (period) { switch (period) {
case "3months": case "3months":
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1) startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1);
break break;
case "6months": case "6months":
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1) startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1);
break break;
case "12months": case "12months":
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1) startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1);
break break;
default: default:
startDate = new Date(0) startDate = new Date(0);
} }
let transactions = data.transactions.filter((t) => new Date(t.date) >= startDate) let transactions = data.transactions.filter(
(t) => new Date(t.date) >= startDate,
);
if (selectedAccount !== "all") { if (selectedAccount !== "all") {
transactions = transactions.filter((t) => t.accountId === selectedAccount) transactions = transactions.filter(
(t) => t.accountId === selectedAccount,
);
} }
// Monthly breakdown // Monthly breakdown
const monthlyData = new Map<string, { income: number; expenses: number }>() const monthlyData = new Map<string, { income: number; expenses: number }>();
transactions.forEach((t) => { transactions.forEach((t) => {
const monthKey = t.date.substring(0, 7) const monthKey = t.date.substring(0, 7);
const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 } const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 };
if (t.amount >= 0) { if (t.amount >= 0) {
current.income += t.amount current.income += t.amount;
} else { } else {
current.expenses += Math.abs(t.amount) current.expenses += Math.abs(t.amount);
} }
monthlyData.set(monthKey, current) monthlyData.set(monthKey, current);
}) });
const monthlyChartData = Array.from(monthlyData.entries()) const monthlyChartData = Array.from(monthlyData.entries())
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.map(([month, values]) => ({ .map(([month, values]) => ({
month: new Date(month + "-01").toLocaleDateString("fr-FR", { month: "short", year: "2-digit" }), month: new Date(month + "-01").toLocaleDateString("fr-FR", {
month: "short",
year: "2-digit",
}),
revenus: Math.round(values.income), revenus: Math.round(values.income),
depenses: Math.round(values.expenses), depenses: Math.round(values.expenses),
solde: Math.round(values.income - values.expenses), solde: Math.round(values.income - values.expenses),
})) }));
// Category breakdown (expenses only) // Category breakdown (expenses only)
const categoryTotals = new Map<string, number>() const categoryTotals = new Map<string, number>();
transactions transactions
.filter((t) => t.amount < 0) .filter((t) => t.amount < 0)
.forEach((t) => { .forEach((t) => {
const catId = t.categoryId || "uncategorized" const catId = t.categoryId || "uncategorized";
const current = categoryTotals.get(catId) || 0 const current = categoryTotals.get(catId) || 0;
categoryTotals.set(catId, current + Math.abs(t.amount)) categoryTotals.set(catId, current + Math.abs(t.amount));
}) });
const categoryChartData = Array.from(categoryTotals.entries()) const categoryChartData = Array.from(categoryTotals.entries())
.map(([categoryId, total]) => { .map(([categoryId, total]) => {
const category = data.categories.find((c) => c.id === categoryId) const category = data.categories.find((c) => c.id === categoryId);
return { return {
name: category?.name || "Non catégorisé", name: category?.name || "Non catégorisé",
value: Math.round(total), value: Math.round(total),
color: category?.color || "#94a3b8", color: category?.color || "#94a3b8",
} };
}) })
.sort((a, b) => b.value - a.value) .sort((a, b) => b.value - a.value)
.slice(0, 8) .slice(0, 8);
// Top expenses // Top expenses
const topExpenses = transactions const topExpenses = transactions
.filter((t) => t.amount < 0) .filter((t) => t.amount < 0)
.sort((a, b) => a.amount - b.amount) .sort((a, b) => a.amount - b.amount)
.slice(0, 5) .slice(0, 5);
// Summary // Summary
const totalIncome = transactions.filter((t) => t.amount >= 0).reduce((sum, t) => sum + t.amount, 0) const totalIncome = transactions
const totalExpenses = transactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + Math.abs(t.amount), 0) .filter((t) => t.amount >= 0)
const avgMonthlyExpenses = monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0 .reduce((sum, t) => sum + t.amount, 0);
const totalExpenses = transactions
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const avgMonthlyExpenses =
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
// Balance evolution // Balance evolution
const sortedTransactions = [...transactions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) const sortedTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
let runningBalance = 0 let runningBalance = 0;
const balanceByDate = new Map<string, number>() const balanceByDate = new Map<string, number>();
sortedTransactions.forEach((t) => { sortedTransactions.forEach((t) => {
runningBalance += t.amount runningBalance += t.amount;
balanceByDate.set(t.date, runningBalance) balanceByDate.set(t.date, runningBalance);
}) });
const balanceChartData = Array.from(balanceByDate.entries()).map(([date, balance]) => ({ const balanceChartData = Array.from(balanceByDate.entries()).map(
date: new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" }), ([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
}),
solde: Math.round(balance), solde: Math.round(balance),
})) }),
);
return { return {
monthlyChartData, monthlyChartData,
@@ -136,8 +161,8 @@ export default function StatisticsPage() {
avgMonthlyExpenses, avgMonthlyExpenses,
balanceChartData, balanceChartData,
transactionCount: transactions.length, transactionCount: transactions.length,
} };
}, [data, period, selectedAccount]) }, [data, period, selectedAccount]);
if (isLoading || !data || !stats) { if (isLoading || !data || !stats) {
return ( return (
@@ -147,15 +172,15 @@ export default function StatisticsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" /> <RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main> </main>
</div> </div>
) );
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(amount) }).format(amount);
} };
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
@@ -164,11 +189,18 @@ export default function StatisticsPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Statistiques</h1> <h1 className="text-2xl font-bold text-foreground">
<p className="text-muted-foreground">Analysez vos dépenses et revenus</p> Statistiques
</h1>
<p className="text-muted-foreground">
Analysez vos dépenses et revenus
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={selectedAccount} onValueChange={setSelectedAccount}> <Select
value={selectedAccount}
onValueChange={setSelectedAccount}
>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" /> <SelectValue placeholder="Compte" />
</SelectTrigger> </SelectTrigger>
@@ -181,7 +213,10 @@ export default function StatisticsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}> <Select
value={period}
onValueChange={(v) => setPeriod(v as Period)}
>
<SelectTrigger className="w-[150px]"> <SelectTrigger className="w-[150px]">
<SelectValue placeholder="Période" /> <SelectValue placeholder="Période" />
</SelectTrigger> </SelectTrigger>
@@ -205,7 +240,9 @@ export default function StatisticsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.totalIncome)}</div> <div className="text-2xl font-bold text-emerald-600">
{formatCurrency(stats.totalIncome)}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -217,7 +254,9 @@ export default function StatisticsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-red-600">{formatCurrency(stats.totalExpenses)}</div> <div className="text-2xl font-bold text-red-600">
{formatCurrency(stats.totalExpenses)}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -229,19 +268,25 @@ export default function StatisticsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatCurrency(stats.avgMonthlyExpenses)}</div> <div className="text-2xl font-bold">
{formatCurrency(stats.avgMonthlyExpenses)}
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Économies</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">
Économies
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div <div
className={cn( className={cn(
"text-2xl font-bold", "text-2xl font-bold",
stats.totalIncome - stats.totalExpenses >= 0 ? "text-emerald-600" : "text-red-600", stats.totalIncome - stats.totalExpenses >= 0
? "text-emerald-600"
: "text-red-600",
)} )}
> >
{formatCurrency(stats.totalIncome - stats.totalExpenses)} {formatCurrency(stats.totalIncome - stats.totalExpenses)}
@@ -262,9 +307,15 @@ export default function StatisticsPage() {
<div className="h-[300px]"> <div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.monthlyChartData}> <BarChart data={stats.monthlyChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis dataKey="month" className="text-xs" /> <XAxis dataKey="month" className="text-xs" />
<YAxis className="text-xs" tickFormatter={(v) => `${v}`} /> <YAxis
className="text-xs"
tickFormatter={(v) => `${v}`}
/>
<Tooltip <Tooltip
formatter={(value: number) => formatCurrency(value)} formatter={(value: number) => formatCurrency(value)}
contentStyle={{ contentStyle={{
@@ -274,8 +325,16 @@ export default function StatisticsPage() {
}} }}
/> />
<Legend /> <Legend />
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} /> <Bar
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} /> dataKey="revenus"
fill="#22c55e"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="depenses"
fill="#ef4444"
radius={[4, 4, 0, 0]}
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -318,7 +377,13 @@ export default function StatisticsPage() {
borderRadius: "8px", borderRadius: "8px",
}} }}
/> />
<Legend formatter={(value) => <span className="text-sm text-foreground">{value}</span>} /> <Legend
formatter={(value) => (
<span className="text-sm text-foreground">
{value}
</span>
)}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -340,9 +405,19 @@ export default function StatisticsPage() {
<div className="h-[300px]"> <div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={stats.balanceChartData}> <LineChart data={stats.balanceChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid
<XAxis dataKey="date" className="text-xs" interval="preserveStartEnd" /> strokeDasharray="3 3"
<YAxis className="text-xs" tickFormatter={(v) => `${v}`} /> className="stroke-muted"
/>
<XAxis
dataKey="date"
className="text-xs"
interval="preserveStartEnd"
/>
<YAxis
className="text-xs"
tickFormatter={(v) => `${v}`}
/>
<Tooltip <Tooltip
formatter={(value: number) => formatCurrency(value)} formatter={(value: number) => formatCurrency(value)}
contentStyle={{ contentStyle={{
@@ -351,7 +426,13 @@ export default function StatisticsPage() {
borderRadius: "8px", borderRadius: "8px",
}} }}
/> />
<Line type="monotone" dataKey="solde" stroke="#6366f1" strokeWidth={2} dot={false} /> <Line
type="monotone"
dataKey="solde"
stroke="#6366f1"
strokeWidth={2}
dot={false}
/>
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -372,24 +453,40 @@ export default function StatisticsPage() {
{stats.topExpenses.length > 0 ? ( {stats.topExpenses.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{stats.topExpenses.map((expense, index) => { {stats.topExpenses.map((expense, index) => {
const category = data.categories.find((c) => c.id === expense.categoryId) const category = data.categories.find(
(c) => c.id === expense.categoryId,
);
return ( return (
<div key={expense.id} className="flex items-center gap-3"> <div
key={expense.id}
className="flex items-center gap-3"
>
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold"> <div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
{index + 1} {index + 1}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{expense.description}</p> <p className="font-medium text-sm truncate">
{expense.description}
</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")} {new Date(expense.date).toLocaleDateString(
"fr-FR",
)}
</span> </span>
{category && ( {category && (
<span <span
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1" className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }} style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
> >
<CategoryIcon icon={category.icon} color={category.color} size={10} /> <CategoryIcon
icon={category.icon}
color={category.color}
size={10}
/>
{category.name} {category.name}
</span> </span>
)} )}
@@ -399,7 +496,7 @@ export default function StatisticsPage() {
{formatCurrency(expense.amount)} {formatCurrency(expense.amount)}
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
) : ( ) : (
@@ -413,5 +510,5 @@ export default function StatisticsPage() {
</div> </div>
</main> </main>
</div> </div>
) );
} }

View File

@@ -1,3 +1,3 @@
export default function Loading() { export default function Loading() {
return null return null;
} }

View File

@@ -1,91 +1,125 @@
"use client" "use client";
import { useState, useMemo } from "react" import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog" import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { CategoryIcon } from "@/components/ui/category-icon" import { CategoryIcon } from "@/components/ui/category-icon";
import { Search, CheckCircle2, Circle, MoreVertical, Tags, Upload, RefreshCw, ArrowUpDown, Check } from "lucide-react" import {
import { cn } from "@/lib/utils" Search,
CheckCircle2,
Circle,
MoreVertical,
Tags,
Upload,
RefreshCw,
ArrowUpDown,
Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
type SortField = "date" | "amount" | "description" type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc" type SortOrder = "asc" | "desc";
export default function TransactionsPage() { export default function TransactionsPage() {
const { data, isLoading, refresh, update } = useBankingData() const { data, isLoading, refresh, update } = useBankingData();
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("");
const [selectedAccount, setSelectedAccount] = useState<string>("all") const [selectedAccount, setSelectedAccount] = useState<string>("all");
const [selectedCategory, setSelectedCategory] = useState<string>("all") const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [showReconciled, setShowReconciled] = useState<string>("all") const [showReconciled, setShowReconciled] = useState<string>("all");
const [sortField, setSortField] = useState<SortField>("date") const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc") const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(new Set()) const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set(),
);
const filteredTransactions = useMemo(() => { const filteredTransactions = useMemo(() => {
if (!data) return [] if (!data) return [];
let transactions = [...data.transactions] let transactions = [...data.transactions];
// Filter by search // Filter by search
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase() const query = searchQuery.toLowerCase();
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.description.toLowerCase().includes(query) || t.memo?.toLowerCase().includes(query), (t) =>
) t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query),
);
} }
// Filter by account // Filter by account
if (selectedAccount !== "all") { if (selectedAccount !== "all") {
transactions = transactions.filter((t) => t.accountId === selectedAccount) transactions = transactions.filter(
(t) => t.accountId === selectedAccount,
);
} }
// Filter by category // Filter by category
if (selectedCategory !== "all") { if (selectedCategory !== "all") {
if (selectedCategory === "uncategorized") { if (selectedCategory === "uncategorized") {
transactions = transactions.filter((t) => !t.categoryId) transactions = transactions.filter((t) => !t.categoryId);
} else { } else {
transactions = transactions.filter((t) => t.categoryId === selectedCategory) transactions = transactions.filter(
(t) => t.categoryId === selectedCategory,
);
} }
} }
// Filter by reconciliation status // Filter by reconciliation status
if (showReconciled !== "all") { if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled" const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter((t) => t.isReconciled === isReconciled) transactions = transactions.filter(
(t) => t.isReconciled === isReconciled,
);
} }
// Sort // Sort
transactions.sort((a, b) => { transactions.sort((a, b) => {
let comparison = 0 let comparison = 0;
switch (sortField) { switch (sortField) {
case "date": case "date":
comparison = new Date(a.date).getTime() - new Date(b.date).getTime() comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
break break;
case "amount": case "amount":
comparison = a.amount - b.amount comparison = a.amount - b.amount;
break break;
case "description": case "description":
comparison = a.description.localeCompare(b.description) comparison = a.description.localeCompare(b.description);
break break;
} }
return sortOrder === "asc" ? comparison : -comparison return sortOrder === "asc" ? comparison : -comparison;
}) });
return transactions return transactions;
}, [data, searchQuery, selectedAccount, selectedCategory, showReconciled, sortField, sortOrder]) }, [
data,
searchQuery,
selectedAccount,
selectedCategory,
showReconciled,
sortField,
sortOrder,
]);
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -95,78 +129,80 @@ export default function TransactionsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" /> <RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main> </main>
</div> </div>
) );
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(amount) }).format(amount);
} };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("fr-FR", { return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
}) });
} };
const toggleReconciled = (transactionId: string) => { const toggleReconciled = (transactionId: string) => {
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? { ...t, isReconciled: !t.isReconciled } : t, t.id === transactionId ? { ...t, isReconciled: !t.isReconciled } : t,
) );
update({ ...data, transactions: updatedTransactions }) update({ ...data, transactions: updatedTransactions });
} };
const setCategory = (transactionId: string, categoryId: string | null) => { const setCategory = (transactionId: string, categoryId: string | null) => {
const updatedTransactions = data.transactions.map((t) => (t.id === transactionId ? { ...t, categoryId } : t)) const updatedTransactions = data.transactions.map((t) =>
update({ ...data, transactions: updatedTransactions }) t.id === transactionId ? { ...t, categoryId } : t,
} );
update({ ...data, transactions: updatedTransactions });
};
const bulkReconcile = (reconciled: boolean) => { const bulkReconcile = (reconciled: boolean) => {
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t, selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
) );
update({ ...data, transactions: updatedTransactions }) update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()) setSelectedTransactions(new Set());
} };
const bulkSetCategory = (categoryId: string | null) => { const bulkSetCategory = (categoryId: string | null) => {
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, categoryId } : t, selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
) );
update({ ...data, transactions: updatedTransactions }) update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()) setSelectedTransactions(new Set());
} };
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedTransactions.size === filteredTransactions.length) { if (selectedTransactions.size === filteredTransactions.length) {
setSelectedTransactions(new Set()) setSelectedTransactions(new Set());
} else { } else {
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id))) setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)));
}
} }
};
const toggleSelectTransaction = (id: string) => { const toggleSelectTransaction = (id: string) => {
const newSelected = new Set(selectedTransactions) const newSelected = new Set(selectedTransactions);
if (newSelected.has(id)) { if (newSelected.has(id)) {
newSelected.delete(id) newSelected.delete(id);
} else { } else {
newSelected.add(id) newSelected.add(id);
}
setSelectedTransactions(newSelected)
} }
setSelectedTransactions(newSelected);
};
const getCategory = (categoryId: string | null) => { const getCategory = (categoryId: string | null) => {
if (!categoryId) return null if (!categoryId) return null;
return data.categories.find((c) => c.id === categoryId) return data.categories.find((c) => c.id === categoryId);
} };
const getAccount = (accountId: string) => { const getAccount = (accountId: string) => {
return data.accounts.find((a) => a.id === accountId) return data.accounts.find((a) => a.id === accountId);
} };
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
@@ -175,9 +211,12 @@ export default function TransactionsPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Transactions</h1> <h1 className="text-2xl font-bold text-foreground">
Transactions
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{filteredTransactions.length} transaction{filteredTransactions.length > 1 ? "s" : ""} {filteredTransactions.length} transaction
{filteredTransactions.length > 1 ? "s" : ""}
</p> </p>
</div> </div>
<OFXImportDialog onImportComplete={refresh}> <OFXImportDialog onImportComplete={refresh}>
@@ -204,7 +243,10 @@ export default function TransactionsPage() {
</div> </div>
</div> </div>
<Select value={selectedAccount} onValueChange={setSelectedAccount}> <Select
value={selectedAccount}
onValueChange={setSelectedAccount}
>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" /> <SelectValue placeholder="Compte" />
</SelectTrigger> </SelectTrigger>
@@ -218,13 +260,18 @@ export default function TransactionsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={selectedCategory} onValueChange={setSelectedCategory}> <Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Catégorie" /> <SelectValue placeholder="Catégorie" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toutes catégories</SelectItem> <SelectItem value="all">Toutes catégories</SelectItem>
<SelectItem value="uncategorized">Non catégorisé</SelectItem> <SelectItem value="uncategorized">
Non catégorisé
</SelectItem>
{data.categories.map((category) => ( {data.categories.map((category) => (
<SelectItem key={category.id} value={category.id}> <SelectItem key={category.id} value={category.id}>
{category.name} {category.name}
@@ -233,7 +280,10 @@ export default function TransactionsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={showReconciled} onValueChange={setShowReconciled}> <Select
value={showReconciled}
onValueChange={setShowReconciled}
>
<SelectTrigger className="w-[160px]"> <SelectTrigger className="w-[160px]">
<SelectValue placeholder="Pointage" /> <SelectValue placeholder="Pointage" />
</SelectTrigger> </SelectTrigger>
@@ -253,13 +303,22 @@ export default function TransactionsPage() {
<CardContent className="py-3"> <CardContent className="py-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{selectedTransactions.size} sélectionnée{selectedTransactions.size > 1 ? "s" : ""} {selectedTransactions.size} sélectionnée
{selectedTransactions.size > 1 ? "s" : ""}
</span> </span>
<Button size="sm" variant="outline" onClick={() => bulkReconcile(true)}> <Button
size="sm"
variant="outline"
onClick={() => bulkReconcile(true)}
>
<CheckCircle2 className="w-4 h-4 mr-1" /> <CheckCircle2 className="w-4 h-4 mr-1" />
Pointer Pointer
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => bulkReconcile(false)}> <Button
size="sm"
variant="outline"
onClick={() => bulkReconcile(false)}
>
<Circle className="w-4 h-4 mr-1" /> <Circle className="w-4 h-4 mr-1" />
Dépointer Dépointer
</Button> </Button>
@@ -271,11 +330,21 @@ export default function TransactionsPage() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>Aucune catégorie</DropdownMenuItem> <DropdownMenuItem onClick={() => bulkSetCategory(null)}>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{data.categories.map((cat) => ( {data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => bulkSetCategory(cat.id)}> <DropdownMenuItem
<CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" /> key={cat.id}
onClick={() => bulkSetCategory(cat.id)}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/>
{cat.name} {cat.name}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -291,7 +360,9 @@ export default function TransactionsPage() {
<CardContent className="p-0"> <CardContent className="p-0">
{filteredTransactions.length === 0 ? ( {filteredTransactions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Aucune transaction trouvée</p> <p className="text-muted-foreground">
Aucune transaction trouvée
</p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -301,7 +372,8 @@ export default function TransactionsPage() {
<th className="p-3 text-left"> <th className="p-3 text-left">
<Checkbox <Checkbox
checked={ checked={
selectedTransactions.size === filteredTransactions.length && selectedTransactions.size ===
filteredTransactions.length &&
filteredTransactions.length > 0 filteredTransactions.length > 0
} }
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
@@ -311,10 +383,12 @@ export default function TransactionsPage() {
<button <button
onClick={() => { onClick={() => {
if (sortField === "date") { if (sortField === "date") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc") setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else { } else {
setSortField("date") setSortField("date");
setSortOrder("desc") setSortOrder("desc");
} }
}} }}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground" className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
@@ -327,10 +401,12 @@ export default function TransactionsPage() {
<button <button
onClick={() => { onClick={() => {
if (sortField === "description") { if (sortField === "description") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc") setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else { } else {
setSortField("description") setSortField("description");
setSortOrder("asc") setSortOrder("asc");
} }
}} }}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground" className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
@@ -339,16 +415,22 @@ export default function TransactionsPage() {
<ArrowUpDown className="w-3 h-3" /> <ArrowUpDown className="w-3 h-3" />
</button> </button>
</th> </th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">Compte</th> <th className="p-3 text-left text-sm font-medium text-muted-foreground">
<th className="p-3 text-left text-sm font-medium text-muted-foreground">Catégorie</th> Compte
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Catégorie
</th>
<th className="p-3 text-right"> <th className="p-3 text-right">
<button <button
onClick={() => { onClick={() => {
if (sortField === "amount") { if (sortField === "amount") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc") setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else { } else {
setSortField("amount") setSortField("amount");
setSortOrder("desc") setSortOrder("desc");
} }
}} }}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto" className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
@@ -357,35 +439,48 @@ export default function TransactionsPage() {
<ArrowUpDown className="w-3 h-3" /> <ArrowUpDown className="w-3 h-3" />
</button> </button>
</th> </th>
<th className="p-3 text-center text-sm font-medium text-muted-foreground">Pointé</th> <th className="p-3 text-center text-sm font-medium text-muted-foreground">
Pointé
</th>
<th className="p-3"></th> <th className="p-3"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredTransactions.map((transaction) => { {filteredTransactions.map((transaction) => {
const category = getCategory(transaction.categoryId) const category = getCategory(transaction.categoryId);
const account = getAccount(transaction.accountId) const account = getAccount(transaction.accountId);
return ( return (
<tr key={transaction.id} className="border-b border-border last:border-0 hover:bg-muted/50"> <tr
key={transaction.id}
className="border-b border-border last:border-0 hover:bg-muted/50"
>
<td className="p-3"> <td className="p-3">
<Checkbox <Checkbox
checked={selectedTransactions.has(transaction.id)} checked={selectedTransactions.has(
onCheckedChange={() => toggleSelectTransaction(transaction.id)} transaction.id,
)}
onCheckedChange={() =>
toggleSelectTransaction(transaction.id)
}
/> />
</td> </td>
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap"> <td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)} {formatDate(transaction.date)}
</td> </td>
<td className="p-3"> <td className="p-3">
<p className="font-medium text-sm">{transaction.description}</p> <p className="font-medium text-sm">
{transaction.description}
</p>
{transaction.memo && ( {transaction.memo && (
<p className="text-xs text-muted-foreground truncate max-w-[300px]"> <p className="text-xs text-muted-foreground truncate max-w-[300px]">
{transaction.memo} {transaction.memo}
</p> </p>
)} )}
</td> </td>
<td className="p-3 text-sm text-muted-foreground">{account?.name || "-"}</td> <td className="p-3 text-sm text-muted-foreground">
{account?.name || "-"}
</td>
<td className="p-3"> <td className="p-3">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -399,26 +494,49 @@ export default function TransactionsPage() {
color: category.color, color: category.color,
}} }}
> >
<CategoryIcon icon={category.icon} color={category.color} size={12} /> <CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
{category.name} {category.name}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-muted-foreground"> <Badge
variant="outline"
className="text-muted-foreground"
>
Non catégorisé Non catégorisé
</Badge> </Badge>
)} )}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onClick={() => setCategory(transaction.id, null)}> <DropdownMenuItem
onClick={() =>
setCategory(transaction.id, null)
}
>
Aucune catégorie Aucune catégorie
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{data.categories.map((cat) => ( {data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => setCategory(transaction.id, cat.id)}> <DropdownMenuItem
<CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" /> key={cat.id}
onClick={() =>
setCategory(transaction.id, cat.id)
}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/>
{cat.name} {cat.name}
{transaction.categoryId === cat.id && <Check className="w-4 h-4 ml-auto" />} {transaction.categoryId === cat.id && (
<Check className="w-4 h-4 ml-auto" />
)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -427,7 +545,9 @@ export default function TransactionsPage() {
<td <td
className={cn( className={cn(
"p-3 text-right font-semibold tabular-nums", "p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0 ? "text-emerald-600" : "text-red-600", transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}
@@ -448,19 +568,29 @@ export default function TransactionsPage() {
<td className="p-3"> <td className="p-3">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => toggleReconciled(transaction.id)}> <DropdownMenuItem
{transaction.isReconciled ? "Dépointer" : "Pointer"} onClick={() =>
toggleReconciled(transaction.id)
}
>
{transaction.isReconciled
? "Dépointer"
: "Pointer"}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</td> </td>
</tr> </tr>
) );
})} })}
</tbody> </tbody>
</table> </table>
@@ -471,5 +601,5 @@ export default function TransactionsPage() {
</div> </div>
</main> </main>
</div> </div>
) );
} }

View File

@@ -1,13 +1,13 @@
"use client" "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress";
import type { BankingData } from "@/lib/types" import type { BankingData } from "@/lib/types";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Building2 } from "lucide-react" import { Building2 } from "lucide-react";
interface AccountsSummaryProps { interface AccountsSummaryProps {
data: BankingData data: BankingData;
} }
export function AccountsSummary({ data }: AccountsSummaryProps) { export function AccountsSummary({ data }: AccountsSummaryProps) {
@@ -15,10 +15,12 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(amount) }).format(amount);
} };
const totalPositive = data.accounts.filter((a) => a.balance > 0).reduce((sum, a) => sum + a.balance, 0) const totalPositive = data.accounts
.filter((a) => a.balance > 0)
.reduce((sum, a) => sum + a.balance, 0);
if (data.accounts.length === 0) { if (data.accounts.length === 0) {
return ( return (
@@ -30,11 +32,13 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<Building2 className="w-12 h-12 text-muted-foreground mb-3" /> <Building2 className="w-12 h-12 text-muted-foreground mb-3" />
<p className="text-muted-foreground">Aucun compte</p> <p className="text-muted-foreground">Aucun compte</p>
<p className="text-sm text-muted-foreground mt-1">Importez un fichier OFX pour ajouter un compte</p> <p className="text-sm text-muted-foreground mt-1">
Importez un fichier OFX pour ajouter un compte
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
return ( return (
@@ -45,7 +49,10 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.accounts.map((account) => { {data.accounts.map((account) => {
const percentage = totalPositive > 0 ? Math.max(0, (account.balance / totalPositive) * 100) : 0 const percentage =
totalPositive > 0
? Math.max(0, (account.balance / totalPositive) * 100)
: 0;
return ( return (
<div key={account.id} className="space-y-2"> <div key={account.id} className="space-y-2">
@@ -57,25 +64,31 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<div> <div>
<p className="font-medium text-sm">{account.name}</p> <p className="font-medium text-sm">{account.name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{account.accountNumber.slice(-4).padStart(account.accountNumber.length, "*")} {account.accountNumber
.slice(-4)
.padStart(account.accountNumber.length, "*")}
</p> </p>
</div> </div>
</div> </div>
<span <span
className={cn( className={cn(
"font-semibold tabular-nums", "font-semibold tabular-nums",
account.balance >= 0 ? "text-emerald-600" : "text-red-600", account.balance >= 0
? "text-emerald-600"
: "text-red-600",
)} )}
> >
{formatCurrency(account.balance)} {formatCurrency(account.balance)}
</span> </span>
</div> </div>
{account.balance > 0 && <Progress value={percentage} className="h-1.5" />} {account.balance > 0 && (
<Progress value={percentage} className="h-1.5" />
)}
</div> </div>
) );
})} })}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }

View File

@@ -1,47 +1,56 @@
"use client" "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { BankingData } from "@/lib/types" import type { BankingData } from "@/lib/types";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts" import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip,
} from "recharts";
interface CategoryBreakdownProps { interface CategoryBreakdownProps {
data: BankingData data: BankingData;
} }
export function CategoryBreakdown({ data }: CategoryBreakdownProps) { export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
// Get current month expenses by category // Get current month expenses by category
const thisMonth = new Date() const thisMonth = new Date();
thisMonth.setDate(1) thisMonth.setDate(1);
const thisMonthStr = thisMonth.toISOString().slice(0, 7) const thisMonthStr = thisMonth.toISOString().slice(0, 7);
const monthExpenses = data.transactions.filter((t) => t.date.startsWith(thisMonthStr) && t.amount < 0) const monthExpenses = data.transactions.filter(
(t) => t.date.startsWith(thisMonthStr) && t.amount < 0,
);
const categoryTotals = new Map<string, number>() const categoryTotals = new Map<string, number>();
monthExpenses.forEach((t) => { monthExpenses.forEach((t) => {
const catId = t.categoryId || "uncategorized" const catId = t.categoryId || "uncategorized";
const current = categoryTotals.get(catId) || 0 const current = categoryTotals.get(catId) || 0;
categoryTotals.set(catId, current + Math.abs(t.amount)) categoryTotals.set(catId, current + Math.abs(t.amount));
}) });
const chartData = Array.from(categoryTotals.entries()) const chartData = Array.from(categoryTotals.entries())
.map(([categoryId, total]) => { .map(([categoryId, total]) => {
const category = data.categories.find((c) => c.id === categoryId) const category = data.categories.find((c) => c.id === categoryId);
return { return {
name: category?.name || "Non catégorisé", name: category?.name || "Non catégorisé",
value: total, value: total,
color: category?.color || "#94a3b8", color: category?.color || "#94a3b8",
} };
}) })
.sort((a, b) => b.value - a.value) .sort((a, b) => b.value - a.value)
.slice(0, 6) .slice(0, 6);
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(value) }).format(value);
} };
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
@@ -55,7 +64,7 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
return ( return (
@@ -88,11 +97,15 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
borderRadius: "8px", borderRadius: "8px",
}} }}
/> />
<Legend formatter={(value) => <span className="text-sm text-foreground">{value}</span>} /> <Legend
formatter={(value) => (
<span className="text-sm text-foreground">{value}</span>
)}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }

View File

@@ -1,46 +1,60 @@
"use client" "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react" import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
import type { BankingData } from "@/lib/types" import type { BankingData } from "@/lib/types";
interface OverviewCardsProps { interface OverviewCardsProps {
data: BankingData data: BankingData;
} }
export function OverviewCards({ data }: OverviewCardsProps) { export function OverviewCards({ data }: OverviewCardsProps) {
const totalBalance = data.accounts.reduce((sum, acc) => sum + acc.balance, 0) const totalBalance = data.accounts.reduce((sum, acc) => sum + acc.balance, 0);
const thisMonth = new Date() const thisMonth = new Date();
thisMonth.setDate(1) thisMonth.setDate(1);
const thisMonthStr = thisMonth.toISOString().slice(0, 7) const thisMonthStr = thisMonth.toISOString().slice(0, 7);
const monthTransactions = data.transactions.filter((t) => t.date.startsWith(thisMonthStr)) const monthTransactions = data.transactions.filter((t) =>
t.date.startsWith(thisMonthStr),
);
const income = monthTransactions.filter((t) => t.amount > 0).reduce((sum, t) => sum + t.amount, 0) const income = monthTransactions
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const expenses = monthTransactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + Math.abs(t.amount), 0) const expenses = monthTransactions
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const reconciled = data.transactions.filter((t) => t.isReconciled).length const reconciled = data.transactions.filter((t) => t.isReconciled).length;
const total = data.transactions.length const total = data.transactions.length;
const reconciledPercent = total > 0 ? Math.round((reconciled / total) * 100) : 0 const reconciledPercent =
total > 0 ? Math.round((reconciled / total) * 100) : 0;
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(amount) }).format(amount);
} };
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Solde Total</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">
Solde Total
</CardTitle>
<Wallet className="h-4 w-4 text-muted-foreground" /> <Wallet className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className={cn("text-2xl font-bold", totalBalance >= 0 ? "text-emerald-600" : "text-red-600")}> <div
className={cn(
"text-2xl font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(totalBalance)} {formatCurrency(totalBalance)}
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
@@ -51,35 +65,49 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Revenus du mois</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">
Revenus du mois
</CardTitle>
<TrendingUp className="h-4 w-4 text-emerald-600" /> <TrendingUp className="h-4 w-4 text-emerald-600" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-emerald-600">{formatCurrency(income)}</div> <div className="text-2xl font-bold text-emerald-600">
{formatCurrency(income)}
</div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{monthTransactions.filter((t) => t.amount > 0).length} opération {monthTransactions.filter((t) => t.amount > 0).length} opération
{monthTransactions.filter((t) => t.amount > 0).length > 1 ? "s" : ""} {monthTransactions.filter((t) => t.amount > 0).length > 1
? "s"
: ""}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Dépenses du mois</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">
Dépenses du mois
</CardTitle>
<TrendingDown className="h-4 w-4 text-red-600" /> <TrendingDown className="h-4 w-4 text-red-600" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-red-600">{formatCurrency(expenses)}</div> <div className="text-2xl font-bold text-red-600">
{formatCurrency(expenses)}
</div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{monthTransactions.filter((t) => t.amount < 0).length} opération {monthTransactions.filter((t) => t.amount < 0).length} opération
{monthTransactions.filter((t) => t.amount < 0).length > 1 ? "s" : ""} {monthTransactions.filter((t) => t.amount < 0).length > 1
? "s"
: ""}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Pointage</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">
Pointage
</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" /> <CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -90,7 +118,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";

View File

@@ -1,43 +1,43 @@
"use client" "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { CheckCircle2, Circle } from "lucide-react" import { CheckCircle2, Circle } from "lucide-react";
import { CategoryIcon } from "@/components/ui/category-icon" import { CategoryIcon } from "@/components/ui/category-icon";
import type { BankingData } from "@/lib/types" import type { BankingData } from "@/lib/types";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
interface RecentTransactionsProps { interface RecentTransactionsProps {
data: BankingData data: BankingData;
} }
export function RecentTransactions({ data }: RecentTransactionsProps) { export function RecentTransactions({ data }: RecentTransactionsProps) {
const recentTransactions = [...data.transactions] const recentTransactions = [...data.transactions]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 10) .slice(0, 10);
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
}).format(amount) }).format(amount);
} };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("fr-FR", { return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
}) });
} };
const getCategory = (categoryId: string | null) => { const getCategory = (categoryId: string | null) => {
if (!categoryId) return null if (!categoryId) return null;
return data.categories.find((c) => c.id === categoryId) return data.categories.find((c) => c.id === categoryId);
} };
const getAccount = (accountId: string) => { const getAccount = (accountId: string) => {
return data.accounts.find((a) => a.id === accountId) return data.accounts.find((a) => a.id === accountId);
} };
if (recentTransactions.length === 0) { if (recentTransactions.length === 0) {
return ( return (
@@ -48,11 +48,13 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
<CardContent> <CardContent>
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-muted-foreground">Aucune transaction</p> <p className="text-muted-foreground">Aucune transaction</p>
<p className="text-sm text-muted-foreground mt-1">Importez un fichier OFX pour commencer</p> <p className="text-sm text-muted-foreground mt-1">
Importez un fichier OFX pour commencer
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
return ( return (
@@ -63,8 +65,8 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{recentTransactions.map((transaction) => { {recentTransactions.map((transaction) => {
const category = getCategory(transaction.categoryId) const category = getCategory(transaction.categoryId);
const account = getAccount(transaction.accountId) const account = getAccount(transaction.accountId);
return ( return (
<div <div
@@ -80,17 +82,32 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{transaction.description}</p> <p className="font-medium truncate">
{transaction.description}
</p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">{formatDate(transaction.date)}</span> <span className="text-xs text-muted-foreground">
{account && <span className="text-xs text-muted-foreground"> {account.name}</span>} {formatDate(transaction.date)}
</span>
{account && (
<span className="text-xs text-muted-foreground">
{account.name}
</span>
)}
{category && ( {category && (
<Badge <Badge
variant="secondary" variant="secondary"
className="text-xs gap-1" className="text-xs gap-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }} style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
> >
<CategoryIcon icon={category.icon} color={category.color} size={12} /> <CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
{category.name} {category.name}
</Badge> </Badge>
)} )}
@@ -100,17 +117,19 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
<div <div
className={cn( className={cn(
"font-semibold tabular-nums", "font-semibold tabular-nums",
transaction.amount >= 0 ? "text-emerald-600" : "text-red-600", transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)} {formatCurrency(transaction.amount)}
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }

View File

@@ -1,10 +1,10 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import Link from "next/link" import Link from "next/link";
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import {
LayoutDashboard, LayoutDashboard,
Wallet, Wallet,
@@ -15,7 +15,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Settings, Settings,
} from "lucide-react" } from "lucide-react";
const navItems = [ const navItems = [
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard }, { href: "/", label: "Tableau de bord", icon: LayoutDashboard },
@@ -24,11 +24,11 @@ const navItems = [
{ href: "/transactions", label: "Transactions", icon: Upload }, { href: "/transactions", label: "Transactions", icon: Upload },
{ href: "/categories", label: "Catégories", icon: Tags }, { href: "/categories", label: "Catégories", icon: Tags },
{ href: "/statistics", label: "Statistiques", icon: BarChart3 }, { href: "/statistics", label: "Statistiques", icon: BarChart3 },
] ];
export function Sidebar() { export function Sidebar() {
const pathname = usePathname() const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false);
return ( return (
<aside <aside
@@ -46,36 +46,54 @@ export function Sidebar() {
<span className="font-semibold text-foreground">FinTrack</span> <span className="font-semibold text-foreground">FinTrack</span>
</div> </div>
)} )}
<Button variant="ghost" size="icon" onClick={() => setCollapsed(!collapsed)} className="ml-auto"> <Button
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />} variant="ghost"
size="icon"
onClick={() => setCollapsed(!collapsed)}
className="ml-auto"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button> </Button>
</div> </div>
<nav className="flex-1 p-2 space-y-1"> <nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = pathname === item.href const isActive = pathname === item.href;
return ( return (
<Link key={item.href} href={item.href}> <Link key={item.href} href={item.href}>
<Button <Button
variant={isActive ? "secondary" : "ghost"} variant={isActive ? "secondary" : "ghost"}
className={cn("w-full justify-start gap-3", collapsed && "justify-center px-2")} className={cn(
"w-full justify-start gap-3",
collapsed && "justify-center px-2",
)}
> >
<item.icon className="w-5 h-5 shrink-0" /> <item.icon className="w-5 h-5 shrink-0" />
{!collapsed && <span>{item.label}</span>} {!collapsed && <span>{item.label}</span>}
</Button> </Button>
</Link> </Link>
) );
})} })}
</nav> </nav>
<div className="p-2 border-t border-border"> <div className="p-2 border-t border-border">
<Link href="/settings"> <Link href="/settings">
<Button variant="ghost" className={cn("w-full justify-start gap-3", collapsed && "justify-center px-2")}> <Button
variant="ghost"
className={cn(
"w-full justify-start gap-3",
collapsed && "justify-center px-2",
)}
>
<Settings className="w-5 h-5 shrink-0" /> <Settings className="w-5 h-5 shrink-0" />
{!collapsed && <span>Paramètres</span>} {!collapsed && <span>Paramètres</span>}
</Button> </Button>
</Link> </Link>
</div> </div>
</aside> </aside>
) );
} }

View File

@@ -1,9 +1,9 @@
"use client" "use client";
import type React from "react" import type React from "react";
import { useState, useCallback } from "react" import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone" import { useDropzone } from "react-dropzone";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,78 +11,109 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import {
import { Progress } from "@/components/ui/progress" Select,
import { Upload, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react" SelectContent,
import { parseOFX } from "@/lib/ofx-parser" SelectItem,
import { loadData, addAccount, updateAccount, addTransactions, generateId, autoCategorize } from "@/lib/store-db" SelectTrigger,
import type { OFXAccount, Account, Transaction, Folder, BankingData } from "@/lib/types" SelectValue,
import { cn } from "@/lib/utils" } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import {
Upload,
FileText,
CheckCircle2,
AlertCircle,
Loader2,
} from "lucide-react";
import { parseOFX } from "@/lib/ofx-parser";
import {
loadData,
addAccount,
updateAccount,
addTransactions,
generateId,
autoCategorize,
} from "@/lib/store-db";
import type {
OFXAccount,
Account,
Transaction,
Folder,
BankingData,
} from "@/lib/types";
import { cn } from "@/lib/utils";
interface OFXImportDialogProps { interface OFXImportDialogProps {
children: React.ReactNode children: React.ReactNode;
onImportComplete?: () => void onImportComplete?: () => void;
} }
type ImportStep = "upload" | "configure" | "importing" | "success" | "error" type ImportStep = "upload" | "configure" | "importing" | "success" | "error";
interface ImportResult { interface ImportResult {
fileName: string fileName: string;
accountName: string accountName: string;
transactionsImported: number transactionsImported: number;
isNew: boolean isNew: boolean;
error?: string error?: string;
} }
export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogProps) { export function OFXImportDialog({
const [open, setOpen] = useState(false) children,
const [step, setStep] = useState<ImportStep>("upload") onImportComplete,
}: OFXImportDialogProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<ImportStep>("upload");
// Single file mode // Single file mode
const [parsedData, setParsedData] = useState<OFXAccount | null>(null) const [parsedData, setParsedData] = useState<OFXAccount | null>(null);
const [accountName, setAccountName] = useState("") const [accountName, setAccountName] = useState("");
const [selectedFolder, setSelectedFolder] = useState<string>("folder-root") const [selectedFolder, setSelectedFolder] = useState<string>("folder-root");
const [folders, setFolders] = useState<Folder[]>([]) const [folders, setFolders] = useState<Folder[]>([]);
const [existingAccountId, setExistingAccountId] = useState<string | null>(null) const [existingAccountId, setExistingAccountId] = useState<string | null>(
null,
);
// Multi-file mode // Multi-file mode
const [importResults, setImportResults] = useState<ImportResult[]>([]) const [importResults, setImportResults] = useState<ImportResult[]>([]);
const [importProgress, setImportProgress] = useState(0) const [importProgress, setImportProgress] = useState(0);
const [totalFiles, setTotalFiles] = useState(0) const [totalFiles, setTotalFiles] = useState(0);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
// Import a single OFX file directly (for multi-file mode) // Import a single OFX file directly (for multi-file mode)
const importOFXDirect = async ( const importOFXDirect = async (
parsed: OFXAccount, parsed: OFXAccount,
fileName: string, fileName: string,
data: BankingData data: BankingData,
): Promise<ImportResult> => { ): Promise<ImportResult> => {
try { try {
// Check if account already exists // Check if account already exists
const existing = data.accounts.find( const existing = data.accounts.find(
(a) => a.accountNumber === parsed.accountId && a.bankId === parsed.bankId (a) =>
) a.accountNumber === parsed.accountId && a.bankId === parsed.bankId,
);
let accountId: string let accountId: string;
let accountName: string let accountName: string;
let isNew = false let isNew = false;
if (existing) { if (existing) {
accountId = existing.id accountId = existing.id;
accountName = existing.name accountName = existing.name;
await updateAccount({ await updateAccount({
...existing, ...existing,
balance: parsed.balance, balance: parsed.balance,
lastImport: new Date().toISOString(), lastImport: new Date().toISOString(),
}) });
} else { } else {
isNew = true isNew = true;
accountName = `Compte ${parsed.accountId.slice(-4)}` accountName = `Compte ${parsed.accountId.slice(-4)}`;
const newAccount = await addAccount({ const newAccount = await addAccount({
name: accountName, name: accountName,
bankId: parsed.bankId, bankId: parsed.bankId,
@@ -92,14 +123,16 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
balance: parsed.balance, balance: parsed.balance,
currency: parsed.currency, currency: parsed.currency,
lastImport: new Date().toISOString(), lastImport: new Date().toISOString(),
}) });
accountId = newAccount.id accountId = newAccount.id;
} }
// Add transactions with auto-categorization // Add transactions with auto-categorization
const existingFitIds = new Set( const existingFitIds = new Set(
data.transactions.filter((t) => t.accountId === accountId).map((t) => t.fitId) data.transactions
) .filter((t) => t.accountId === accountId)
.map((t) => t.fitId),
);
const newTransactions: Transaction[] = parsed.transactions const newTransactions: Transaction[] = parsed.transactions
.filter((t) => !existingFitIds.has(t.fitId)) .filter((t) => !existingFitIds.has(t.fitId))
@@ -110,15 +143,18 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
amount: t.amount, amount: t.amount,
description: t.name, description: t.name,
type: t.amount >= 0 ? "CREDIT" : "DEBIT", type: t.amount >= 0 ? "CREDIT" : "DEBIT",
categoryId: autoCategorize(t.name + " " + (t.memo || ""), data.categories), categoryId: autoCategorize(
t.name + " " + (t.memo || ""),
data.categories,
),
isReconciled: false, isReconciled: false,
fitId: t.fitId, fitId: t.fitId,
memo: t.memo, memo: t.memo,
checkNum: t.checkNum, checkNum: t.checkNum,
})) }));
if (newTransactions.length > 0) { if (newTransactions.length > 0) {
await addTransactions(newTransactions) await addTransactions(newTransactions);
} }
return { return {
@@ -126,7 +162,7 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
accountName, accountName,
transactionsImported: newTransactions.length, transactionsImported: newTransactions.length,
isNew, isNew,
} };
} catch (err) { } catch (err) {
return { return {
fileName, fileName,
@@ -134,33 +170,34 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
transactionsImported: 0, transactionsImported: 0,
isNew: false, isNew: false,
error: err instanceof Error ? err.message : "Erreur inconnue", error: err instanceof Error ? err.message : "Erreur inconnue",
};
} }
} };
}
const onDrop = useCallback(async (acceptedFiles: File[]) => { const onDrop = useCallback(
if (acceptedFiles.length === 0) return async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
// Multi-file mode: import directly // Multi-file mode: import directly
if (acceptedFiles.length > 1) { if (acceptedFiles.length > 1) {
setStep("importing") setStep("importing");
setTotalFiles(acceptedFiles.length) setTotalFiles(acceptedFiles.length);
setImportProgress(0) setImportProgress(0);
setImportResults([]) setImportResults([]);
const data = await loadData() const data = await loadData();
const results: ImportResult[] = [] const results: ImportResult[] = [];
for (let i = 0; i < acceptedFiles.length; i++) { for (let i = 0; i < acceptedFiles.length; i++) {
const file = acceptedFiles[i] const file = acceptedFiles[i];
const content = await file.text() const content = await file.text();
const parsed = parseOFX(content) const parsed = parseOFX(content);
if (parsed) { if (parsed) {
// Reload data after each import to get updated accounts/transactions // Reload data after each import to get updated accounts/transactions
const freshData = i === 0 ? data : await loadData() const freshData = i === 0 ? data : await loadData();
const result = await importOFXDirect(parsed, file.name, freshData) const result = await importOFXDirect(parsed, file.name, freshData);
results.push(result) results.push(result);
} else { } else {
results.push({ results.push({
fileName: file.name, fileName: file.name,
@@ -168,51 +205,55 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
transactionsImported: 0, transactionsImported: 0,
isNew: false, isNew: false,
error: "Format OFX invalide", error: "Format OFX invalide",
}) });
} }
setImportProgress(((i + 1) / acceptedFiles.length) * 100) setImportProgress(((i + 1) / acceptedFiles.length) * 100);
setImportResults([...results]) setImportResults([...results]);
} }
setStep("success") setStep("success");
onImportComplete?.() onImportComplete?.();
return return;
} }
// Single file mode: show configuration // Single file mode: show configuration
const file = acceptedFiles[0] const file = acceptedFiles[0];
const content = await file.text() const content = await file.text();
const parsed = parseOFX(content) const parsed = parseOFX(content);
if (parsed) { if (parsed) {
setParsedData(parsed) setParsedData(parsed);
setAccountName(`Compte ${parsed.accountId.slice(-4)}`) setAccountName(`Compte ${parsed.accountId.slice(-4)}`);
try { try {
const data = await loadData() const data = await loadData();
setFolders(data.folders) setFolders(data.folders);
const existing = data.accounts.find( const existing = data.accounts.find(
(a) => a.accountNumber === parsed.accountId && a.bankId === parsed.bankId (a) =>
) a.accountNumber === parsed.accountId &&
a.bankId === parsed.bankId,
);
if (existing) { if (existing) {
setExistingAccountId(existing.id) setExistingAccountId(existing.id);
setAccountName(existing.name) setAccountName(existing.name);
setSelectedFolder(existing.folderId || "folder-root") setSelectedFolder(existing.folderId || "folder-root");
} }
setStep("configure") setStep("configure");
} catch (err) { } catch (err) {
console.error("Error loading data:", err) console.error("Error loading data:", err);
setError("Erreur lors du chargement des données") setError("Erreur lors du chargement des données");
setStep("error") setStep("error");
} }
} else { } else {
setError("Impossible de lire le fichier OFX. Vérifiez le format.") setError("Impossible de lire le fichier OFX. Vérifiez le format.");
setStep("error") setStep("error");
} }
}, [onImportComplete]) },
[onImportComplete],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
@@ -222,20 +263,22 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
"text/plain": [".ofx", ".qfx"], "text/plain": [".ofx", ".qfx"],
}, },
// No maxFiles limit - accept multiple files // No maxFiles limit - accept multiple files
}) });
const handleImport = async () => { const handleImport = async () => {
if (!parsedData) return if (!parsedData) return;
try { try {
setStep("importing") setStep("importing");
const data = await loadData() const data = await loadData();
let accountId: string let accountId: string;
if (existingAccountId) { if (existingAccountId) {
accountId = existingAccountId accountId = existingAccountId;
const existingAccount = data.accounts.find((a) => a.id === existingAccountId) const existingAccount = data.accounts.find(
(a) => a.id === existingAccountId,
);
if (existingAccount) { if (existingAccount) {
await updateAccount({ await updateAccount({
...existingAccount, ...existingAccount,
@@ -243,7 +286,7 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
folderId: selectedFolder, folderId: selectedFolder,
balance: parsedData.balance, balance: parsedData.balance,
lastImport: new Date().toISOString(), lastImport: new Date().toISOString(),
}) });
} }
} else { } else {
const newAccount = await addAccount({ const newAccount = await addAccount({
@@ -255,13 +298,15 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
balance: parsedData.balance, balance: parsedData.balance,
currency: parsedData.currency, currency: parsedData.currency,
lastImport: new Date().toISOString(), lastImport: new Date().toISOString(),
}) });
accountId = newAccount.id accountId = newAccount.id;
} }
const existingFitIds = new Set( const existingFitIds = new Set(
data.transactions.filter((t) => t.accountId === accountId).map((t) => t.fitId) data.transactions
) .filter((t) => t.accountId === accountId)
.map((t) => t.fitId),
);
const newTransactions: Transaction[] = parsedData.transactions const newTransactions: Transaction[] = parsedData.transactions
.filter((t) => !existingFitIds.has(t.fitId)) .filter((t) => !existingFitIds.has(t.fitId))
@@ -272,57 +317,65 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
amount: t.amount, amount: t.amount,
description: t.name, description: t.name,
type: t.amount >= 0 ? "CREDIT" : "DEBIT", type: t.amount >= 0 ? "CREDIT" : "DEBIT",
categoryId: autoCategorize(t.name + " " + (t.memo || ""), data.categories), categoryId: autoCategorize(
t.name + " " + (t.memo || ""),
data.categories,
),
isReconciled: false, isReconciled: false,
fitId: t.fitId, fitId: t.fitId,
memo: t.memo, memo: t.memo,
checkNum: t.checkNum, checkNum: t.checkNum,
})) }));
if (newTransactions.length > 0) { if (newTransactions.length > 0) {
await addTransactions(newTransactions) await addTransactions(newTransactions);
} }
setImportResults([{ setImportResults([
{
fileName: "Import", fileName: "Import",
accountName, accountName,
transactionsImported: newTransactions.length, transactionsImported: newTransactions.length,
isNew: !existingAccountId, isNew: !existingAccountId,
}]) },
setStep("success") ]);
onImportComplete?.() setStep("success");
onImportComplete?.();
} catch (err) { } catch (err) {
console.error("Error importing:", err) console.error("Error importing:", err);
setError("Erreur lors de l'import") setError("Erreur lors de l'import");
setStep("error") setStep("error");
}
} }
};
const handleClose = () => { const handleClose = () => {
setOpen(false) setOpen(false);
setTimeout(() => { setTimeout(() => {
setStep("upload") setStep("upload");
setParsedData(null) setParsedData(null);
setAccountName("") setAccountName("");
setSelectedFolder("folder-root") setSelectedFolder("folder-root");
setExistingAccountId(null) setExistingAccountId(null);
setError(null) setError(null);
setImportResults([]) setImportResults([]);
setImportProgress(0) setImportProgress(0);
setTotalFiles(0) setTotalFiles(0);
}, 200) }, 200);
} };
const totalTransactions = importResults.reduce((sum, r) => sum + r.transactionsImported, 0) const totalTransactions = importResults.reduce(
const successCount = importResults.filter((r) => !r.error).length (sum, r) => sum + r.transactionsImported,
const errorCount = importResults.filter((r) => r.error).length 0,
);
const successCount = importResults.filter((r) => !r.error).length;
const errorCount = importResults.filter((r) => r.error).length;
return ( return (
<Dialog <Dialog
open={open} open={open}
onOpenChange={(o) => { onOpenChange={(o) => {
if (!o) handleClose() if (!o) handleClose();
else setOpen(true) else setOpen(true);
}} }}
> >
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
@@ -336,14 +389,16 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
{step === "error" && "Erreur d'import"} {step === "error" && "Erreur d'import"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{step === "upload" && "Glissez-déposez vos fichiers OFX ou cliquez pour sélectionner"} {step === "upload" &&
{step === "configure" && "Vérifiez les informations du compte avant l'import"} "Glissez-déposez vos fichiers OFX ou cliquez pour sélectionner"}
{step === "importing" && `Import de ${totalFiles} fichier${totalFiles > 1 ? "s" : ""}...`} {step === "configure" &&
{step === "success" && ( "Vérifiez les informations du compte avant l'import"}
importResults.length > 1 {step === "importing" &&
`Import de ${totalFiles} fichier${totalFiles > 1 ? "s" : ""}...`}
{step === "success" &&
(importResults.length > 1
? `${successCount} fichier${successCount > 1 ? "s" : ""} importé${successCount > 1 ? "s" : ""}, ${totalTransactions} transactions` ? `${successCount} fichier${successCount > 1 ? "s" : ""} importé${successCount > 1 ? "s" : ""}, ${totalTransactions} transactions`
: `${totalTransactions} nouvelles transactions importées` : `${totalTransactions} nouvelles transactions importées`)}
)}
{step === "error" && error} {step === "error" && error}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -353,16 +408,21 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors", "border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50", isDragActive
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50",
)} )}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
<Upload className="w-10 h-10 mx-auto mb-4 text-muted-foreground" /> <Upload className="w-10 h-10 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isDragActive ? "Déposez les fichiers ici..." : "Fichiers .ofx ou .qfx acceptés"} {isDragActive
? "Déposez les fichiers ici..."
: "Fichiers .ofx ou .qfx acceptés"}
</p> </p>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Un fichier = configuration manuelle Plusieurs fichiers = import direct Un fichier = configuration manuelle Plusieurs fichiers = import
direct
</p> </p>
</div> </div>
)} )}
@@ -372,12 +432,15 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg"> <div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<FileText className="w-8 h-8 text-primary" /> <FileText className="w-8 h-8 text-primary" />
<div> <div>
<p className="font-medium">{parsedData.transactions.length} transactions</p> <p className="font-medium">
{parsedData.transactions.length} transactions
</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Solde:{" "} Solde:{" "}
{new Intl.NumberFormat("fr-FR", { style: "currency", currency: parsedData.currency }).format( {new Intl.NumberFormat("fr-FR", {
parsedData.balance, style: "currency",
)} currency: parsedData.currency,
}).format(parsedData.balance)}
</p> </p>
</div> </div>
</div> </div>
@@ -410,7 +473,8 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
{existingAccountId && ( {existingAccountId && (
<p className="text-sm text-amber-600 bg-amber-50 p-2 rounded"> <p className="text-sm text-amber-600 bg-amber-50 p-2 rounded">
Ce compte existe déjà. Les nouvelles transactions seront ajoutées. Ce compte existe déjà. Les nouvelles transactions seront
ajoutées.
</p> </p>
)} )}
@@ -468,14 +532,21 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" /> <CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate font-medium">{result.accountName}</p> <p className="truncate font-medium">
<p className="text-xs text-muted-foreground truncate">{result.fileName}</p> {result.accountName}
</p>
<p className="text-xs text-muted-foreground truncate">
{result.fileName}
</p>
</div> </div>
{result.error ? ( {result.error ? (
<span className="text-xs text-red-500 flex-shrink-0">{result.error}</span> <span className="text-xs text-red-500 flex-shrink-0">
{result.error}
</span>
) : ( ) : (
<span className="text-xs text-muted-foreground flex-shrink-0"> <span className="text-xs text-muted-foreground flex-shrink-0">
{result.isNew ? "Nouveau" : "Mis à jour"} +{result.transactionsImported} {result.isNew ? "Nouveau" : "Mis à jour"} +
{result.transactionsImported}
</span> </span>
)} )}
</div> </div>
@@ -503,5 +574,5 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }

View File

@@ -1,11 +1,11 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import { import {
ThemeProvider as NextThemesProvider, ThemeProvider as NextThemesProvider,
type ThemeProviderProps, type ThemeProviderProps,
} from 'next-themes' } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider> return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
} }

View File

@@ -1,15 +1,15 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as AccordionPrimitive from '@radix-ui/react-accordion' import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Accordion({ function Accordion({
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) { }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} /> return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
} }
function AccordionItem({ function AccordionItem({
@@ -19,10 +19,10 @@ function AccordionItem({
return ( return (
<AccordionPrimitive.Item <AccordionPrimitive.Item
data-slot="accordion-item" data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)} className={cn("border-b last:border-b-0", className)}
{...props} {...props}
/> />
) );
} }
function AccordionTrigger({ function AccordionTrigger({
@@ -35,7 +35,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
data-slot="accordion-trigger" data-slot="accordion-trigger"
className={cn( className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180', "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className, className,
)} )}
{...props} {...props}
@@ -44,7 +44,7 @@ function AccordionTrigger({
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
) );
} }
function AccordionContent({ function AccordionContent({
@@ -58,9 +58,9 @@ function AccordionContent({
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm" className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props} {...props}
> >
<div className={cn('pt-0 pb-4', className)}>{children}</div> <div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>
) );
} }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -1,15 +1,15 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from "@/components/ui/button";
function AlertDialog({ function AlertDialog({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
) );
} }
function AlertDialogPortal({ function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return ( return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
) );
} }
function AlertDialogOverlay({ function AlertDialogOverlay({
@@ -36,12 +36,12 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -54,42 +54,42 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className, className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({
className, className,
...props ...props
}: React.ComponentProps<'div'>) { }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-header" data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({
className, className,
...props ...props
}: React.ComponentProps<'div'>) { }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@@ -99,10 +99,10 @@ function AlertDialogTitle({
return ( return (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -112,10 +112,10 @@ function AlertDialogDescription({
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -136,10 +136,10 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return ( return (
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)} className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };

View File

@@ -1,29 +1,29 @@
import * as React from 'react' import * as React from "react";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-card text-card-foreground', default: "bg-card text-card-foreground",
destructive: destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
function Alert({ function Alert({
className, className,
variant, variant,
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return ( return (
<div <div
data-slot="alert" data-slot="alert"
@@ -31,36 +31,36 @@ function Alert({
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-title" data-slot="alert-title"
className={cn( className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDescription({ function AlertDescription({
className, className,
...props ...props
}: React.ComponentProps<'div'>) { }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-description" data-slot="alert-description"
className={cn( className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,11 +1,11 @@
'use client' "use client";
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({ function AspectRatio({
...props ...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) { }: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} /> return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
} }
export { AspectRatio } export { AspectRatio };

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as AvatarPrimitive from '@radix-ui/react-avatar' import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Avatar({ function Avatar({
className, className,
@@ -13,12 +13,12 @@ function Avatar({
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
className={cn( className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full', "relative flex size-8 shrink-0 overflow-hidden rounded-full",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarImage({ function AvatarImage({
@@ -28,10 +28,10 @@ function AvatarImage({
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn('aspect-square size-full', className)} className={cn("aspect-square size-full", className)}
{...props} {...props}
/> />
) );
} }
function AvatarFallback({ function AvatarFallback({
@@ -42,12 +42,12 @@ function AvatarFallback({
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
'bg-muted flex size-full items-center justify-center rounded-full', "bg-muted flex size-full items-center justify-center rounded-full",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -1,38 +1,38 @@
import * as React from 'react' import * as React from "react";
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{ {
variants: { variants: {
variant: { variant: {
default: default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
function Badge({ function Badge({
className, className,
variant, variant,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'span'> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span' const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@@ -1,101 +1,101 @@
import * as React from 'react' import * as React from "react";
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from 'lucide-react' import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) { function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return ( return (
<ol <ol
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="breadcrumb-item" data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)} className={cn("inline-flex items-center gap-1.5", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbLink({ function BreadcrumbLink({
asChild, asChild,
className, className,
...props ...props
}: React.ComponentProps<'a'> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : 'a' const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
data-slot="breadcrumb-link" data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)} className={cn("hover:text-foreground transition-colors", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="breadcrumb-page" data-slot="breadcrumb-page"
role="link" role="link"
aria-disabled="true" aria-disabled="true"
aria-current="page" aria-current="page"
className={cn('text-foreground font-normal', className)} className={cn("text-foreground font-normal", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbSeparator({ function BreadcrumbSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<'li'>) { }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="breadcrumb-separator" data-slot="breadcrumb-separator"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)} className={cn("[&>svg]:size-3.5", className)}
{...props} {...props}
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) );
} }
function BreadcrumbEllipsis({ function BreadcrumbEllipsis({
className, className,
...props ...props
}: React.ComponentProps<'span'>) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="breadcrumb-ellipsis" data-slot="breadcrumb-ellipsis"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)} className={cn("flex size-9 items-center justify-center", className)}
{...props} {...props}
> >
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) );
} }
export { export {
@@ -106,4 +106,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };

View File

@@ -1,8 +1,8 @@
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator' import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva( const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
@@ -10,22 +10,22 @@ const buttonGroupVariants = cva(
variants: { variants: {
orientation: { orientation: {
horizontal: horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none', "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical: vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none', "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
}, },
}, },
defaultVariants: { defaultVariants: {
orientation: 'horizontal', orientation: "horizontal",
}, },
}, },
) );
function ButtonGroup({ function ButtonGroup({
className, className,
orientation, orientation,
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return ( return (
<div <div
role="group" role="group"
@@ -34,17 +34,17 @@ function ButtonGroup({
className={cn(buttonGroupVariants({ orientation }), className)} className={cn(buttonGroupVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function ButtonGroupText({ function ButtonGroupText({
className, className,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : 'div' const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
@@ -54,12 +54,12 @@ function ButtonGroupText({
)} )}
{...props} {...props}
/> />
) );
} }
function ButtonGroupSeparator({ function ButtonGroupSeparator({
className, className,
orientation = 'vertical', orientation = "vertical",
...props ...props
}: React.ComponentProps<typeof Separator>) { }: React.ComponentProps<typeof Separator>) {
return ( return (
@@ -67,12 +67,12 @@ function ButtonGroupSeparator({
data-slot="button-group-separator" data-slot="button-group-separator"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto', "bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -80,4 +80,4 @@ export {
ButtonGroupSeparator, ButtonGroupSeparator,
ButtonGroupText, ButtonGroupText,
buttonGroupVariants, buttonGroupVariants,
} };

View File

@@ -1,40 +1,40 @@
import * as React from 'react' import * as React from "react";
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80', "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: 'text-primary underline-offset-4 hover:underline', link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: 'size-9', icon: "size-9",
'icon-sm': 'size-8', "icon-sm": "size-8",
'icon-lg': 'size-10', "icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) );
function Button({ function Button({
className, className,
@@ -42,11 +42,11 @@ function Button({
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'button'> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -54,7 +54,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -1,35 +1,35 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from 'lucide-react' } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker' import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Button, buttonVariants } from '@/components/ui/button' import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({ function Calendar({
className, className,
classNames, classNames,
showOutsideDays = true, showOutsideDays = true,
captionLayout = 'label', captionLayout = "label",
buttonVariant = 'ghost', buttonVariant = "ghost",
formatters, formatters,
components, components,
...props ...props
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'] buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) { }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn( className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent', "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className, className,
@@ -37,91 +37,91 @@ function Calendar({
captionLayout={captionLayout} captionLayout={captionLayout}
formatters={{ formatters={{
formatMonthDropdown: (date) => formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }), date.toLocaleString("default", { month: "short" }),
...formatters, ...formatters,
}} }}
classNames={{ classNames={{
root: cn('w-fit', defaultClassNames.root), root: cn("w-fit", defaultClassNames.root),
months: cn( months: cn(
'flex gap-4 flex-col md:flex-row relative', "flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months, defaultClassNames.months,
), ),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month), month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn( nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between', "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav, defaultClassNames.nav,
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous, defaultClassNames.button_previous,
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none', "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next, defaultClassNames.button_next,
), ),
month_caption: cn( month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)', "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption, defaultClassNames.month_caption,
), ),
dropdowns: cn( dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5', "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns, defaultClassNames.dropdowns,
), ),
dropdown_root: cn( dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md', "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root, defaultClassNames.dropdown_root,
), ),
dropdown: cn( dropdown: cn(
'absolute bg-popover inset-0 opacity-0', "absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown, defaultClassNames.dropdown,
), ),
caption_label: cn( caption_label: cn(
'select-none font-medium', "select-none font-medium",
captionLayout === 'label' captionLayout === "label"
? 'text-sm' ? "text-sm"
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5', : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label, defaultClassNames.caption_label,
), ),
table: 'w-full border-collapse', table: "w-full border-collapse",
weekdays: cn('flex', defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none', "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday, defaultClassNames.weekday,
), ),
week: cn('flex w-full mt-2', defaultClassNames.week), week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn( week_number_header: cn(
'select-none w-(--cell-size)', "select-none w-(--cell-size)",
defaultClassNames.week_number_header, defaultClassNames.week_number_header,
), ),
week_number: cn( week_number: cn(
'text-[0.8rem] select-none text-muted-foreground', "text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number, defaultClassNames.week_number,
), ),
day: cn( day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none', "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day, defaultClassNames.day,
), ),
range_start: cn( range_start: cn(
'rounded-l-md bg-accent', "rounded-l-md bg-accent",
defaultClassNames.range_start, defaultClassNames.range_start,
), ),
range_middle: cn('rounded-none', defaultClassNames.range_middle), range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end), range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn( today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today, defaultClassNames.today,
), ),
outside: cn( outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground', "text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside, defaultClassNames.outside,
), ),
disabled: cn( disabled: cn(
'text-muted-foreground opacity-50', "text-muted-foreground opacity-50",
defaultClassNames.disabled, defaultClassNames.disabled,
), ),
hidden: cn('invisible', defaultClassNames.hidden), hidden: cn("invisible", defaultClassNames.hidden),
...classNames, ...classNames,
}} }}
components={{ components={{
@@ -133,27 +133,27 @@ function Calendar({
className={cn(className)} className={cn(className)}
{...props} {...props}
/> />
) );
}, },
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') { if (orientation === "left") {
return ( return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} /> <ChevronLeftIcon className={cn("size-4", className)} {...props} />
) );
} }
if (orientation === 'right') { if (orientation === "right") {
return ( return (
<ChevronRightIcon <ChevronRightIcon
className={cn('size-4', className)} className={cn("size-4", className)}
{...props} {...props}
/> />
) );
} }
return ( return (
<ChevronDownIcon className={cn('size-4', className)} {...props} /> <ChevronDownIcon className={cn("size-4", className)} {...props} />
) );
}, },
DayButton: CalendarDayButton, DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
@@ -163,13 +163,13 @@ function Calendar({
{children} {children}
</div> </div>
</td> </td>
) );
}, },
...components, ...components,
}} }}
{...props} {...props}
/> />
) );
} }
function CalendarDayButton({ function CalendarDayButton({
@@ -178,12 +178,12 @@ function CalendarDayButton({
modifiers, modifiers,
...props ...props
}: React.ComponentProps<typeof DayButton>) { }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null) const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (modifiers.focused) ref.current?.focus() if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]) }, [modifiers.focused]);
return ( return (
<Button <Button
@@ -201,13 +201,13 @@ function CalendarDayButton({
data-range-end={modifiers.range_end} data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle} data-range-middle={modifiers.range_middle}
className={cn( className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70', "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Calendar, CalendarDayButton } export { Calendar, CalendarDayButton };

View File

@@ -1,84 +1,84 @@
import * as React from 'react' import * as React from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<'div'>) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn('leading-none font-semibold', className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<'div'>) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end', "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<'div'>) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn('px-6', className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@@ -1,108 +1,108 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from 'embla-carousel-react' } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from 'lucide-react' import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0] type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1] type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = { type CarouselProps = {
opts?: CarouselOptions opts?: CarouselOptions;
plugins?: CarouselPlugin plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical' orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void;
} };
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error('useCarousel must be used within a <Carousel />') throw new Error("useCarousel must be used within a <Carousel />");
} }
return context return context;
} }
function Carousel({ function Carousel({
orientation = 'horizontal', orientation = "horizontal",
opts, opts,
setApi, setApi,
plugins, plugins,
className, className,
children, children,
...props ...props
}: React.ComponentProps<'div'> & CarouselProps) { }: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel( const [carouselRef, api] = useEmblaCarousel(
{ {
...opts, ...opts,
axis: orientation === 'horizontal' ? 'x' : 'y', axis: orientation === "horizontal" ? "x" : "y",
}, },
plugins, plugins,
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return if (!api) return;
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') { if (event.key === "ArrowLeft") {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === 'ArrowRight') { } else if (event.key === "ArrowRight") {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext], [scrollPrev, scrollNext],
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) return if (!api || !setApi) return;
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) return if (!api) return;
onSelect(api) onSelect(api);
api.on('reInit', onSelect) api.on("reInit", onSelect);
api.on('select', onSelect) api.on("select", onSelect);
return () => { return () => {
api?.off('select', onSelect) api?.off("select", onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@@ -111,7 +111,7 @@ function Carousel({
api: api, api: api,
opts, opts,
orientation: orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'), orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev, scrollPrev,
scrollNext, scrollNext,
canScrollPrev, canScrollPrev,
@@ -120,7 +120,7 @@ function Carousel({
> >
<div <div
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
className={cn('relative', className)} className={cn("relative", className)}
role="region" role="region"
aria-roledescription="carousel" aria-roledescription="carousel"
data-slot="carousel" data-slot="carousel"
@@ -129,11 +129,11 @@ function Carousel({
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} }
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) { function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div <div
@@ -143,18 +143,18 @@ function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
> >
<div <div
className={cn( className={cn(
'flex', "flex",
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className, className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) { function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@@ -162,22 +162,22 @@ function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
aria-roledescription="slide" aria-roledescription="slide"
data-slot="carousel-item" data-slot="carousel-item"
className={cn( className={cn(
'min-w-0 shrink-0 grow-0 basis-full', "min-w-0 shrink-0 grow-0 basis-full",
orientation === 'horizontal' ? 'pl-4' : 'pt-4', orientation === "horizontal" ? "pl-4" : "pt-4",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function CarouselPrevious({ function CarouselPrevious({
className, className,
variant = 'outline', variant = "outline",
size = 'icon', size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@@ -185,10 +185,10 @@ function CarouselPrevious({
variant={variant} variant={variant}
size={size} size={size}
className={cn( className={cn(
'absolute size-8 rounded-full', "absolute size-8 rounded-full",
orientation === 'horizontal' orientation === "horizontal"
? 'top-1/2 -left-12 -translate-y-1/2' ? "top-1/2 -left-12 -translate-y-1/2"
: '-top-12 left-1/2 -translate-x-1/2 rotate-90', : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className, className,
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
@@ -198,16 +198,16 @@ function CarouselPrevious({
<ArrowLeft /> <ArrowLeft />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
} }
function CarouselNext({ function CarouselNext({
className, className,
variant = 'outline', variant = "outline",
size = 'icon', size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@@ -215,10 +215,10 @@ function CarouselNext({
variant={variant} variant={variant}
size={size} size={size}
className={cn( className={cn(
'absolute size-8 rounded-full', "absolute size-8 rounded-full",
orientation === 'horizontal' orientation === "horizontal"
? 'top-1/2 -right-12 -translate-y-1/2' ? "top-1/2 -right-12 -translate-y-1/2"
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className, className,
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
@@ -228,7 +228,7 @@ function CarouselNext({
<ArrowRight /> <ArrowRight />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
} }
export { export {
@@ -238,4 +238,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@@ -1,4 +1,4 @@
"use client" "use client";
import { import {
ShoppingCart, ShoppingCart,
@@ -58,86 +58,90 @@ import {
Key, Key,
Refrigerator, Refrigerator,
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react";
// Map icon names to Lucide components // Map icon names to Lucide components
const iconMap: Record<string, LucideIcon> = { const iconMap: Record<string, LucideIcon> = {
"shopping-cart": ShoppingCart, "shopping-cart": ShoppingCart,
"utensils": Utensils, utensils: Utensils,
"croissant": Croissant, croissant: Croissant,
"fuel": Fuel, fuel: Fuel,
"train": Train, train: Train,
"car": Car, car: Car,
"car-taxi": Car, // Using Car as fallback for car-taxi "car-taxi": Car, // Using Car as fallback for car-taxi
"car-key": Key, // Using Key as fallback "car-key": Key, // Using Key as fallback
"parking": SquareParking, parking: SquareParking,
"bike": Bike, bike: Bike,
"plane": Plane, plane: Plane,
"home": Home, home: Home,
"zap": Zap, zap: Zap,
"droplet": Droplet, droplet: Droplet,
"hammer": Hammer, hammer: Hammer,
"sofa": Sofa, sofa: Sofa,
"refrigerator": Refrigerator, refrigerator: Refrigerator,
"pill": Pill, pill: Pill,
"stethoscope": Stethoscope, stethoscope: Stethoscope,
"hospital": Hospital, hospital: Hospital,
"glasses": Glasses, glasses: Glasses,
"dumbbell": Dumbbell, dumbbell: Dumbbell,
"sparkles": Sparkles, sparkles: Sparkles,
"tv": Tv, tv: Tv,
"music": Music, music: Music,
"film": Film, film: Film,
"gamepad": Gamepad, gamepad: Gamepad,
"book": Book, book: Book,
"ticket": Ticket, ticket: Ticket,
"shirt": Shirt, shirt: Shirt,
"smartphone": Smartphone, smartphone: Smartphone,
"package": Package, package: Package,
"wifi": Wifi, wifi: Wifi,
"repeat": Repeat, repeat: Repeat,
"landmark": Landmark, landmark: Landmark,
"shield": Shield, shield: Shield,
"heart-pulse": HeartPulse, "heart-pulse": HeartPulse,
"receipt": Receipt, receipt: Receipt,
"piggy-bank": PiggyBank, "piggy-bank": PiggyBank,
"banknote": Banknote, banknote: Banknote,
"wallet": Wallet, wallet: Wallet,
"hand-coins": HandCoins, "hand-coins": HandCoins,
"undo": Undo, undo: Undo,
"coins": Coins, coins: Coins,
"bed": Bed, bed: Bed,
"luggage": Luggage, luggage: Luggage,
"graduation-cap": GraduationCap, "graduation-cap": GraduationCap,
"baby": Baby, baby: Baby,
"paw-print": PawPrint, "paw-print": PawPrint,
"wrench": Wrench, wrench: Wrench,
"heart-handshake": HeartHandshake, "heart-handshake": HeartHandshake,
"gift": Gift, gift: Gift,
"cigarette": Cigarette, cigarette: Cigarette,
"arrow-right-left": ArrowRightLeft, "arrow-right-left": ArrowRightLeft,
"help-circle": HelpCircle, "help-circle": HelpCircle,
"tag": Tag, tag: Tag,
"folder": Folder, folder: Folder,
} };
// Get all available icon names // Get all available icon names
export const availableIcons = Object.keys(iconMap) export const availableIcons = Object.keys(iconMap);
// Get the icon component by name // Get the icon component by name
export function getIconComponent(iconName: string): LucideIcon { export function getIconComponent(iconName: string): LucideIcon {
return iconMap[iconName] || Tag return iconMap[iconName] || Tag;
} }
interface CategoryIconProps { interface CategoryIconProps {
icon: string icon: string;
color?: string color?: string;
className?: string className?: string;
size?: number size?: number;
} }
export function CategoryIcon({ icon, color, className, size = 20 }: CategoryIconProps) { export function CategoryIcon({
const IconComponent = getIconComponent(icon) icon,
return <IconComponent className={className} style={{ color }} size={size} /> color,
className,
size = 20,
}: CategoryIconProps) {
const IconComponent = getIconComponent(icon);
return <IconComponent className={className} style={{ color }} size={size} />;
} }

View File

@@ -1,37 +1,37 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as RechartsPrimitive from 'recharts' import * as RechartsPrimitive from "recharts";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = { export type ChartConfig = {
[k in string]: { [k in string]: {
label?: React.ReactNode label?: React.ReactNode;
icon?: React.ComponentType icon?: React.ComponentType;
} & ( } & (
| { color?: string; theme?: never } | { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> } | { color?: never; theme: Record<keyof typeof THEMES, string> }
) );
} };
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig config: ChartConfig;
} };
const ChartContext = React.createContext<ChartContextProps | null>(null) const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() { function useChart() {
const context = React.useContext(ChartContext) const context = React.useContext(ChartContext);
if (!context) { if (!context) {
throw new Error('useChart must be used within a <ChartContainer />') throw new Error("useChart must be used within a <ChartContainer />");
} }
return context return context;
} }
function ChartContainer({ function ChartContainer({
@@ -40,14 +40,14 @@ function ChartContainer({
children, children,
config, config,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
config: ChartConfig config: ChartConfig;
children: React.ComponentProps< children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer typeof RechartsPrimitive.ResponsiveContainer
>['children'] >["children"];
}) { }) {
const uniqueId = React.useId() const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
@@ -66,16 +66,16 @@ function ChartContainer({
</RechartsPrimitive.ResponsiveContainer> </RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> </ChartContext.Provider>
) );
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color, ([, config]) => config.theme || config.color,
) );
if (!colorConfig.length) { if (!colorConfig.length) {
return null return null;
} }
return ( return (
@@ -89,35 +89,35 @@ ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color itemConfig.color;
return color ? ` --color-${key}: ${color};` : null return color ? ` --color-${key}: ${color};` : null;
}) })
.join('\n')} .join("\n")}
} }
`, `,
) )
.join('\n'), .join("\n"),
}} }}
/> />
) );
} };
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip;
type TooltipPayloadItem = { type TooltipPayloadItem = {
dataKey?: string | number dataKey?: string | number;
name?: string name?: string;
value?: number | string value?: number | string;
color?: string color?: string;
payload?: Record<string, unknown> & { fill?: string } payload?: Record<string, unknown> & { fill?: string };
fill?: string fill?: string;
} };
function ChartTooltipContent({ function ChartTooltipContent({
active, active,
payload, payload,
className, className,
indicator = 'dot', indicator = "dot",
hideLabel = false, hideLabel = false,
hideIndicator = false, hideIndicator = false,
label, label,
@@ -127,44 +127,47 @@ function ChartTooltipContent({
color, color,
nameKey, nameKey,
labelKey, labelKey,
}: Omit<React.ComponentProps<typeof RechartsPrimitive.Tooltip>, 'payload' | 'label'> & }: Omit<
React.ComponentProps<'div'> & { React.ComponentProps<typeof RechartsPrimitive.Tooltip>,
hideLabel?: boolean "payload" | "label"
hideIndicator?: boolean > &
indicator?: 'line' | 'dot' | 'dashed' React.ComponentProps<"div"> & {
nameKey?: string hideLabel?: boolean;
labelKey?: string hideIndicator?: boolean;
payload?: TooltipPayloadItem[] indicator?: "line" | "dot" | "dashed";
label?: string | number nameKey?: string;
labelKey?: string;
payload?: TooltipPayloadItem[];
label?: string | number;
}) { }) {
const { config } = useChart() const { config } = useChart();
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
return null return null;
} }
const [item] = payload const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}` const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = const value =
!labelKey && typeof label === 'string' !labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label ? config[label as keyof typeof config]?.label || label
: itemConfig?.label : itemConfig?.label;
if (labelFormatter) { if (labelFormatter) {
return ( return (
<div className={cn('font-medium', labelClassName)}> <div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)} {labelFormatter(value, payload)}
</div> </div>
) );
} }
if (!value) { if (!value) {
return null return null;
} }
return <div className={cn('font-medium', labelClassName)}>{value}</div> return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [ }, [
label, label,
labelFormatter, labelFormatter,
@@ -173,38 +176,44 @@ function ChartTooltipContent({
labelClassName, labelClassName,
config, config,
labelKey, labelKey,
]) ]);
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null return null;
} }
const nestLabel = payload.length === 1 && indicator !== 'dot' const nestLabel = payload.length === 1 && indicator !== "dot";
return ( return (
<div <div
className={cn( className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl', "border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className, className,
)} )}
> >
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{payload.map((item: TooltipPayloadItem, index: number) => { {payload.map((item: TooltipPayloadItem, index: number) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}` const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color const indicatorColor = color || item.payload?.fill || item.color;
return ( return (
<div <div
key={item.dataKey} key={item.dataKey}
className={cn( className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5', "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === 'dot' && 'items-center', indicator === "dot" && "items-center",
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item as never, index, item.payload as never) formatter(
item.value,
item.name,
item as never,
index,
item.payload as never,
)
) : ( ) : (
<> <>
{itemConfig?.icon ? ( {itemConfig?.icon ? (
@@ -213,19 +222,19 @@ function ChartTooltipContent({
!hideIndicator && ( !hideIndicator && (
<div <div
className={cn( className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{ {
'h-2.5 w-2.5': indicator === 'dot', "h-2.5 w-2.5": indicator === "dot",
'w-1': indicator === 'line', "w-1": indicator === "line",
'w-0 border-[1.5px] border-dashed bg-transparent': "w-0 border-[1.5px] border-dashed bg-transparent":
indicator === 'dashed', indicator === "dashed",
'my-0.5': nestLabel && indicator === 'dashed', "my-0.5": nestLabel && indicator === "dashed",
}, },
)} )}
style={ style={
{ {
'--color-bg': indicatorColor, "--color-bg": indicatorColor,
'--color-border': indicatorColor, "--color-border": indicatorColor,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
@@ -233,8 +242,8 @@ function ChartTooltipContent({
)} )}
<div <div
className={cn( className={cn(
'flex flex-1 justify-between leading-none', "flex flex-1 justify-between leading-none",
nestLabel ? 'items-end' : 'items-center', nestLabel ? "items-end" : "items-center",
)} )}
> >
<div className="grid gap-1.5"> <div className="grid gap-1.5">
@@ -252,56 +261,56 @@ function ChartTooltipContent({
</> </>
)} )}
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
) );
} }
const ChartLegend = RechartsPrimitive.Legend const ChartLegend = RechartsPrimitive.Legend;
type LegendPayloadItem = { type LegendPayloadItem = {
value?: string value?: string;
dataKey?: string | number dataKey?: string | number;
color?: string color?: string;
} };
function ChartLegendContent({ function ChartLegendContent({
className, className,
hideIcon = false, hideIcon = false,
payload, payload,
verticalAlign = 'bottom', verticalAlign = "bottom",
nameKey, nameKey,
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
hideIcon?: boolean hideIcon?: boolean;
nameKey?: string nameKey?: string;
payload?: LegendPayloadItem[] payload?: LegendPayloadItem[];
verticalAlign?: 'top' | 'bottom' | 'middle' verticalAlign?: "top" | "bottom" | "middle";
}) { }) {
const { config } = useChart() const { config } = useChart();
if (!payload?.length) { if (!payload?.length) {
return null return null;
} }
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-center gap-4', "flex items-center justify-center gap-4",
verticalAlign === 'top' ? 'pb-3' : 'pt-3', verticalAlign === "top" ? "pb-3" : "pt-3",
className, className,
)} )}
> >
{payload.map((item: LegendPayloadItem) => { {payload.map((item: LegendPayloadItem) => {
const key = `${nameKey || item.dataKey || 'value'}` const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
return ( return (
<div <div
key={item.value} key={item.value}
className={ className={
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3' "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
} }
> >
{itemConfig?.icon && !hideIcon ? ( {itemConfig?.icon && !hideIcon ? (
@@ -316,10 +325,10 @@ function ChartLegendContent({
)} )}
{itemConfig?.label} {itemConfig?.label}
</div> </div>
) );
})} })}
</div> </div>
) );
} }
// Helper to extract item config from a payload. // Helper to extract item config from a payload.
@@ -328,37 +337,37 @@ function getPayloadConfigFromPayload(
payload: unknown, payload: unknown,
key: string, key: string,
) { ) {
if (typeof payload !== 'object' || payload === null) { if (typeof payload !== "object" || payload === null) {
return undefined return undefined;
} }
const payloadPayload = const payloadPayload =
'payload' in payload && "payload" in payload &&
typeof payload.payload === 'object' && typeof payload.payload === "object" &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined : undefined;
let configLabelKey: string = key let configLabelKey: string = key;
if ( if (
key in payload && key in payload &&
typeof payload[key as keyof typeof payload] === 'string' typeof payload[key as keyof typeof payload] === "string"
) { ) {
configLabelKey = payload[key as keyof typeof payload] as string configLabelKey = payload[key as keyof typeof payload] as string;
} else if ( } else if (
payloadPayload && payloadPayload &&
key in payloadPayload && key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string' typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) { ) {
configLabelKey = payloadPayload[ configLabelKey = payloadPayload[
key as keyof typeof payloadPayload key as keyof typeof payloadPayload
] as string ] as string;
} }
return configLabelKey in config return configLabelKey in config
? config[configLabelKey] ? config[configLabelKey]
: config[key as keyof typeof config] : config[key as keyof typeof config];
} }
export { export {
@@ -368,4 +377,4 @@ export {
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartStyle, ChartStyle,
} };

View File

@@ -1,10 +1,10 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as CheckboxPrimitive from '@radix-ui/react-checkbox' import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from 'lucide-react' import { CheckIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Checkbox({ function Checkbox({
className, className,
@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };

View File

@@ -1,11 +1,11 @@
'use client' "use client";
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({ function Collapsible({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger" data-slot="collapsible-trigger"
{...props} {...props}
/> />
) );
} }
function CollapsibleContent({ function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content" data-slot="collapsible-content"
{...props} {...props}
/> />
) );
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent } export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,17 +1,17 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import { Command as CommandPrimitive } from 'cmdk' import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from 'lucide-react' import { SearchIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
function Command({ function Command({
className, className,
@@ -21,26 +21,26 @@ function Command({
<CommandPrimitive <CommandPrimitive
data-slot="command" data-slot="command"
className={cn( className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function CommandDialog({ function CommandDialog({
title = 'Command Palette', title = "Command Palette",
description = 'Search for a command to run...', description = "Search for a command to run...",
children, children,
className, className,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof Dialog> & { }: React.ComponentProps<typeof Dialog> & {
title?: string title?: string;
description?: string description?: string;
className?: string className?: string;
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
@@ -49,7 +49,7 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogContent <DialogContent
className={cn('overflow-hidden p-0', className)} className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
> >
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
@@ -57,7 +57,7 @@ function CommandDialog({
</Command> </Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
function CommandInput({ function CommandInput({
@@ -73,13 +73,13 @@ function CommandInput({
<CommandPrimitive.Input <CommandPrimitive.Input
data-slot="command-input" data-slot="command-input"
className={cn( className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function CommandList({ function CommandList({
@@ -90,12 +90,12 @@ function CommandList({
<CommandPrimitive.List <CommandPrimitive.List
data-slot="command-list" data-slot="command-list"
className={cn( className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function CommandEmpty({ function CommandEmpty({
@@ -107,7 +107,7 @@ function CommandEmpty({
className="py-6 text-center text-sm" className="py-6 text-center text-sm"
{...props} {...props}
/> />
) );
} }
function CommandGroup({ function CommandGroup({
@@ -118,12 +118,12 @@ function CommandGroup({
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" data-slot="command-group"
className={cn( className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium', "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function CommandSeparator({ function CommandSeparator({
@@ -133,10 +133,10 @@ function CommandSeparator({
return ( return (
<CommandPrimitive.Separator <CommandPrimitive.Separator
data-slot="command-separator" data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)} className={cn("bg-border -mx-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function CommandItem({ function CommandItem({
@@ -152,23 +152,23 @@ function CommandItem({
)} )}
{...props} {...props}
/> />
) );
} }
function CommandShortcut({ function CommandShortcut({
className, className,
...props ...props
}: React.ComponentProps<'span'>) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="command-shortcut" data-slot="command-shortcut"
className={cn( className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest', "text-muted-foreground ml-auto text-xs tracking-widest",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -181,4 +181,4 @@ export {
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, CommandSeparator,
} };

View File

@@ -1,15 +1,15 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu' import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function ContextMenu({ function ContextMenu({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} /> return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
} }
function ContextMenuTrigger({ function ContextMenuTrigger({
@@ -17,7 +17,7 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return ( return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} /> <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
) );
} }
function ContextMenuGroup({ function ContextMenuGroup({
@@ -25,7 +25,7 @@ function ContextMenuGroup({
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return ( return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
) );
} }
function ContextMenuPortal({ function ContextMenuPortal({
@@ -33,13 +33,13 @@ function ContextMenuPortal({
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return ( return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
) );
} }
function ContextMenuSub({ function ContextMenuSub({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} /> return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
} }
function ContextMenuRadioGroup({ function ContextMenuRadioGroup({
@@ -50,7 +50,7 @@ function ContextMenuRadioGroup({
data-slot="context-menu-radio-group" data-slot="context-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function ContextMenuSubTrigger({ function ContextMenuSubTrigger({
@@ -59,7 +59,7 @@ function ContextMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
@@ -74,7 +74,7 @@ function ContextMenuSubTrigger({
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
) );
} }
function ContextMenuSubContent({ function ContextMenuSubContent({
@@ -85,12 +85,12 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content" data-slot="context-menu-sub-content"
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuContent({ function ContextMenuContent({
@@ -102,23 +102,23 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
data-slot="context-menu-content" data-slot="context-menu-content"
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className, className,
)} )}
{...props} {...props}
/> />
</ContextMenuPrimitive.Portal> </ContextMenuPrimitive.Portal>
) );
} }
function ContextMenuItem({ function ContextMenuItem({
className, className,
inset, inset,
variant = 'default', variant = "default",
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: 'default' | 'destructive' variant?: "default" | "destructive";
}) { }) {
return ( return (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
@@ -131,7 +131,7 @@ function ContextMenuItem({
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuCheckboxItem({ function ContextMenuCheckboxItem({
@@ -157,7 +157,7 @@ function ContextMenuCheckboxItem({
</span> </span>
{children} {children}
</ContextMenuPrimitive.CheckboxItem> </ContextMenuPrimitive.CheckboxItem>
) );
} }
function ContextMenuRadioItem({ function ContextMenuRadioItem({
@@ -181,7 +181,7 @@ function ContextMenuRadioItem({
</span> </span>
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>
) );
} }
function ContextMenuLabel({ function ContextMenuLabel({
@@ -189,19 +189,19 @@ function ContextMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
data-slot="context-menu-label" data-slot="context-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', "text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuSeparator({ function ContextMenuSeparator({
@@ -211,26 +211,26 @@ function ContextMenuSeparator({
return ( return (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
data-slot="context-menu-separator" data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function ContextMenuShortcut({ function ContextMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<'span'>) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="context-menu-shortcut" data-slot="context-menu-shortcut"
className={cn( className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest', "text-muted-foreground ml-auto text-xs tracking-widest",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -249,4 +249,4 @@ export {
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuRadioGroup, ContextMenuRadioGroup,
} };

View File

@@ -1,33 +1,33 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from 'lucide-react' import { XIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -38,12 +38,12 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className, className,
)} )}
{...props} {...props}
@@ -77,30 +77,30 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@@ -110,10 +110,10 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -123,10 +123,10 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -140,4 +140,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@@ -1,32 +1,32 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import { Drawer as DrawerPrimitive } from 'vaul' import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Drawer({ function Drawer({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) { }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} /> return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
} }
function DrawerTrigger({ function DrawerTrigger({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
} }
function DrawerPortal({ function DrawerPortal({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) { }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
} }
function DrawerClose({ function DrawerClose({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) { }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
} }
function DrawerOverlay({ function DrawerOverlay({
@@ -37,12 +37,12 @@ function DrawerOverlay({
<DrawerPrimitive.Overlay <DrawerPrimitive.Overlay
data-slot="drawer-overlay" data-slot="drawer-overlay"
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerContent({ function DrawerContent({
@@ -56,11 +56,11 @@ function DrawerContent({
<DrawerPrimitive.Content <DrawerPrimitive.Content
data-slot="drawer-content" data-slot="drawer-content"
className={cn( className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col', "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b', "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t', "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm', "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm', "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className, className,
)} )}
{...props} {...props}
@@ -69,30 +69,30 @@ function DrawerContent({
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
) );
} }
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) { function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="drawer-header" data-slot="drawer-header"
className={cn( className={cn(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left', "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) { function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="drawer-footer" data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function DrawerTitle({ function DrawerTitle({
@@ -102,10 +102,10 @@ function DrawerTitle({
return ( return (
<DrawerPrimitive.Title <DrawerPrimitive.Title
data-slot="drawer-title" data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DrawerDescription({ function DrawerDescription({
@@ -115,10 +115,10 @@ function DrawerDescription({
return ( return (
<DrawerPrimitive.Description <DrawerPrimitive.Description
data-slot="drawer-description" data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -132,4 +132,4 @@ export {
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} };

View File

@@ -1,15 +1,15 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@@ -42,13 +42,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className, className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@@ -56,17 +56,17 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = 'default', variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: 'default' | 'destructive' variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -79,7 +79,7 @@ function DropdownMenuItem({
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@@ -170,32 +170,32 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<'span'>) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest', "text-muted-foreground ml-auto text-xs tracking-widest",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -219,7 +219,7 @@ function DropdownMenuSubTrigger({
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@@ -1,53 +1,53 @@
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<'div'>) { function Empty({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty" data-slot="empty"
className={cn( className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12', "flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-header" data-slot="empty-header"
className={cn( className={cn(
'flex max-w-sm flex-col items-center gap-2 text-center', "flex max-w-sm flex-col items-center gap-2 text-center",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
const emptyMediaVariants = cva( const emptyMediaVariants = cva(
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0', "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-transparent', default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
function EmptyMedia({ function EmptyMedia({
className, className,
variant = 'default', variant = "default",
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return ( return (
<div <div
data-slot="empty-icon" data-slot="empty-icon"
@@ -55,43 +55,43 @@ function EmptyMedia({
className={cn(emptyMediaVariants({ variant, className }))} className={cn(emptyMediaVariants({ variant, className }))}
{...props} {...props}
/> />
) );
} }
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-title" data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)} className={cn("text-lg font-medium tracking-tight", className)}
{...props} {...props}
/> />
) );
} }
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<div <div
data-slot="empty-description" data-slot="empty-description"
className={cn( className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', "text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-content" data-slot="empty-content"
className={cn( className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance', "flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -101,4 +101,4 @@ export {
EmptyDescription, EmptyDescription,
EmptyContent, EmptyContent,
EmptyMedia, EmptyMedia,
} };

View File

@@ -1,88 +1,88 @@
'use client' "use client";
import { useMemo } from 'react' import { useMemo } from "react";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { Separator } from '@/components/ui/separator' import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
<fieldset <fieldset
data-slot="field-set" data-slot="field-set"
className={cn( className={cn(
'flex flex-col gap-6', "flex flex-col gap-6",
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLegend({ function FieldLegend({
className, className,
variant = 'legend', variant = "legend",
...props ...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return ( return (
<legend <legend
data-slot="field-legend" data-slot="field-legend"
data-variant={variant} data-variant={variant}
className={cn( className={cn(
'mb-3 font-medium', "mb-3 font-medium",
'data-[variant=legend]:text-base', "data-[variant=legend]:text-base",
'data-[variant=label]:text-sm', "data-[variant=label]:text-sm",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4', "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
const fieldVariants = cva( const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive', "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{ {
variants: { variants: {
orientation: { orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'], vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [ horizontal: [
'flex-row items-center', "flex-row items-center",
'[&>[data-slot=field-label]]:flex-auto', "[&>[data-slot=field-label]]:flex-auto",
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
responsive: [ responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto', "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
'@md/field-group:[&>[data-slot=field-label]]:flex-auto', "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
}, },
}, },
defaultVariants: { defaultVariants: {
orientation: 'vertical', orientation: "vertical",
}, },
}, },
) );
function Field({ function Field({
className, className,
orientation = 'vertical', orientation = "vertical",
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return ( return (
<div <div
role="group" role="group"
@@ -91,20 +91,20 @@ function Field({
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-content" data-slot="field-content"
className={cn( className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug', "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLabel({ function FieldLabel({
@@ -115,57 +115,57 @@ function FieldLabel({
<Label <Label
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50', "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4', "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10', "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) { function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50', "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) { function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance', "text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5', "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4', "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldSeparator({ function FieldSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
children?: React.ReactNode children?: React.ReactNode;
}) { }) {
return ( return (
<div <div
data-slot="field-separator" data-slot="field-separator"
data-content={!!children} data-content={!!children}
className={cn( className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2', "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className, className,
)} )}
{...props} {...props}
@@ -180,7 +180,7 @@ function FieldSeparator({
</span> </span>
)} )}
</div> </div>
) );
} }
function FieldError({ function FieldError({
@@ -188,20 +188,20 @@ function FieldError({
children, children,
errors, errors,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>;
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children;
} }
if (!errors) { if (!errors) {
return null return null;
} }
if (errors.length === 1 && errors[0]?.message) { if (errors.length === 1 && errors[0]?.message) {
return errors[0].message return errors[0].message;
} }
return ( return (
@@ -211,23 +211,23 @@ function FieldError({
error?.message && <li key={index}>{error.message}</li>, error?.message && <li key={index}>{error.message}</li>,
)} )}
</ul> </ul>
) );
}, [children, errors]) }, [children, errors]);
if (!content) { if (!content) {
return null return null;
} }
return ( return (
<div <div
role="alert" role="alert"
data-slot="field-error" data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)} className={cn("text-destructive text-sm font-normal", className)}
{...props} {...props}
> >
{content} {content}
</div> </div>
) );
} }
export { export {
@@ -241,4 +241,4 @@ export {
FieldSet, FieldSet,
FieldContent, FieldContent,
FieldTitle, FieldTitle,
} };

View File

@@ -1,8 +1,8 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as LabelPrimitive from '@radix-ui/react-label' import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
FormProvider, FormProvider,
@@ -11,23 +11,23 @@ import {
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from 'react-hook-form' } from "react-hook-form";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue, {} as FormFieldContextValue,
) );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
@@ -39,21 +39,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext() const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name }) const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>') throw new Error("useFormField should be used within <FormField>");
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@@ -62,50 +62,51 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue, {} as FormItemContextValue,
) );
function FormItem({ className, ...props }: React.ComponentProps<'div'>) { function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId() const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div <div
data-slot="form-item" data-slot="form-item"
className={cn('grid gap-2', className)} className={cn("grid gap-2", className)}
{...props} {...props}
/> />
</FormItemContext.Provider> </FormItemContext.Provider>
) );
} }
function FormLabel({ function FormLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField();
return ( return (
<Label <Label
data-slot="form-label" data-slot="form-label"
data-error={!!error} data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)} className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return ( return (
<Slot <Slot
@@ -119,40 +120,40 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
} }
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) { function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
data-slot="form-description" data-slot="form-description"
id={formDescriptionId} id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children const body = error ? String(error?.message ?? "") : props.children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
<p <p
data-slot="form-message" data-slot="form-message"
id={formMessageId} id={formMessageId}
className={cn('text-destructive text-sm', className)} className={cn("text-destructive text-sm", className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
) );
} }
export { export {
@@ -164,4 +165,4 @@ export {
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} };

View File

@@ -1,14 +1,14 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as HoverCardPrimitive from '@radix-ui/react-hover-card' import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function HoverCard({ function HoverCard({
...props ...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) { }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} /> return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
} }
function HoverCardTrigger({ function HoverCardTrigger({
@@ -16,12 +16,12 @@ function HoverCardTrigger({
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) { }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return ( return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} /> <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
) );
} }
function HoverCardContent({ function HoverCardContent({
className, className,
align = 'center', align = "center",
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) { }: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
@@ -32,13 +32,13 @@ function HoverCardContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className, className,
)} )}
{...props} {...props}
/> />
</HoverCardPrimitive.Portal> </HoverCardPrimitive.Portal>
) );
} }
export { HoverCard, HoverCardTrigger, HoverCardContent } export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -1,38 +1,38 @@
'use client' "use client";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Textarea } from '@/components/ui/textarea' import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none', "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
'h-9 has-[>textarea]:h-auto', "h-9 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2', "has-[>[data-align=inline-start]]:[&>input]:pl-2",
'has-[>[data-align=inline-end]]:[&>input]:pr-2', "has-[>[data-align=inline-end]]:[&>input]:pr-2",
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3', "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3', "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state. // Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]', "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state. // Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40', "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
const inputGroupAddonVariants = cva( const inputGroupAddonVariants = cva(
@@ -40,27 +40,27 @@ const inputGroupAddonVariants = cva(
{ {
variants: { variants: {
align: { align: {
'inline-start': "inline-start":
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]', "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
'inline-end': "inline-end":
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]', "order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
'block-start': "block-start":
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5', "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
'block-end': "block-end":
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5', "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
}, },
}, },
defaultVariants: { defaultVariants: {
align: 'inline-start', align: "inline-start",
}, },
}, },
) );
function InputGroupAddon({ function InputGroupAddon({
className, className,
align = 'inline-start', align = "inline-start",
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return ( return (
<div <div
role="group" role="group"
@@ -68,41 +68,41 @@ function InputGroupAddon({
data-align={align} data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)} className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) { if ((e.target as HTMLElement).closest("button")) {
return return;
} }
e.currentTarget.parentElement?.querySelector('input')?.focus() e.currentTarget.parentElement?.querySelector("input")?.focus();
}} }}
{...props} {...props}
/> />
) );
} }
const inputGroupButtonVariants = cva( const inputGroupButtonVariants = cva(
'text-sm shadow-none flex gap-2 items-center', "text-sm shadow-none flex gap-2 items-center",
{ {
variants: { variants: {
size: { size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5', sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
'icon-xs': "icon-xs":
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0', "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
'icon-sm': 'size-8 p-0 has-[>svg]:p-0', "icon-sm": "size-8 p-0 has-[>svg]:p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
size: 'xs', size: "xs",
}, },
}, },
) );
function InputGroupButton({ function InputGroupButton({
className, className,
type = 'button', type = "button",
variant = 'ghost', variant = "ghost",
size = 'xs', size = "xs",
...props ...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> & }: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) { VariantProps<typeof inputGroupButtonVariants>) {
return ( return (
<Button <Button
@@ -112,10 +112,10 @@ function InputGroupButton({
className={cn(inputGroupButtonVariants({ size }), className)} className={cn(inputGroupButtonVariants({ size }), className)}
{...props} {...props}
/> />
) );
} }
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) { function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
className={cn( className={cn(
@@ -124,39 +124,39 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
)} )}
{...props} {...props}
/> />
) );
} }
function InputGroupInput({ function InputGroupInput({
className, className,
...props ...props
}: React.ComponentProps<'input'>) { }: React.ComponentProps<"input">) {
return ( return (
<Input <Input
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent', "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function InputGroupTextarea({ function InputGroupTextarea({
className, className,
...props ...props
}: React.ComponentProps<'textarea'>) { }: React.ComponentProps<"textarea">) {
return ( return (
<Textarea <Textarea
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent', "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -166,4 +166,4 @@ export {
InputGroupText, InputGroupText,
InputGroupInput, InputGroupInput,
InputGroupTextarea, InputGroupTextarea,
} };

View File

@@ -1,57 +1,57 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import { OTPInput, OTPInputContext } from 'input-otp' import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from 'lucide-react' import { MinusIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function InputOTP({ function InputOTP({
className, className,
containerClassName, containerClassName,
...props ...props
}: React.ComponentProps<typeof OTPInput> & { }: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string containerClassName?: string;
}) { }) {
return ( return (
<OTPInput <OTPInput
data-slot="input-otp" data-slot="input-otp"
containerClassName={cn( containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50', "flex items-center gap-2 has-disabled:opacity-50",
containerClassName, containerClassName,
)} )}
className={cn('disabled:cursor-not-allowed', className)} className={cn("disabled:cursor-not-allowed", className)}
{...props} {...props}
/> />
) );
} }
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) { function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="input-otp-group" data-slot="input-otp-group"
className={cn('flex items-center', className)} className={cn("flex items-center", className)}
{...props} {...props}
/> />
) );
} }
function InputOTPSlot({ function InputOTPSlot({
index, index,
className, className,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
index: number index: number;
}) { }) {
const inputOTPContext = React.useContext(OTPInputContext) const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return ( return (
<div <div
data-slot="input-otp-slot" data-slot="input-otp-slot"
data-active={isActive} data-active={isActive}
className={cn( className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]', "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className, className,
)} )}
{...props} {...props}
@@ -63,15 +63,15 @@ function InputOTPSlot({
</div> </div>
)} )}
</div> </div>
) );
} }
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) { function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return ( return (
<div data-slot="input-otp-separator" role="separator" {...props}> <div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon /> <MinusIcon />
</div> </div>
) );
} }
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,21 +1,21 @@
import * as React from 'react' import * as React from "react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<'input'>) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@@ -1,19 +1,19 @@
import * as React from 'react' import * as React from "react";
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator' import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) { function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
role="list" role="list"
data-slot="item-group" data-slot="item-group"
className={cn('group/item-group flex flex-col', className)} className={cn("group/item-group flex flex-col", className)}
{...props} {...props}
/> />
) );
} }
function ItemSeparator({ function ItemSeparator({
@@ -24,42 +24,42 @@ function ItemSeparator({
<Separator <Separator
data-slot="item-separator" data-slot="item-separator"
orientation="horizontal" orientation="horizontal"
className={cn('my-0', className)} className={cn("my-0", className)}
{...props} {...props}
/> />
) );
} }
const itemVariants = cva( const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', "group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-transparent', default: "bg-transparent",
outline: 'border-border', outline: "border-border",
muted: 'bg-muted/50', muted: "bg-muted/50",
}, },
size: { size: {
default: 'p-4 gap-4 ', default: "p-4 gap-4 ",
sm: 'py-3 px-4 gap-2.5', sm: "py-3 px-4 gap-2.5",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) );
function Item({ function Item({
className, className,
variant = 'default', variant = "default",
size = 'default', size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'div'> & }: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) { VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div' const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
data-slot="item" data-slot="item"
@@ -68,31 +68,31 @@ function Item({
className={cn(itemVariants({ variant, size, className }))} className={cn(itemVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
const itemMediaVariants = cva( const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5', "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-transparent', default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4", icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image: image:
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover', "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
function ItemMedia({ function ItemMedia({
className, className,
variant = 'default', variant = "default",
...props ...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return ( return (
<div <div
data-slot="item-media" data-slot="item-media"
@@ -100,83 +100,83 @@ function ItemMedia({
className={cn(itemMediaVariants({ variant, className }))} className={cn(itemMediaVariants({ variant, className }))}
{...props} {...props}
/> />
) );
} }
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) { function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-content" data-slot="item-content"
className={cn( className={cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', "flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) { function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-title" data-slot="item-title"
className={cn( className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium', "flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) { function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
data-slot="item-description" data-slot="item-description"
className={cn( className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance', "text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4', "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) { function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-actions" data-slot="item-actions"
className={cn('flex items-center gap-2', className)} className={cn("flex items-center gap-2", className)}
{...props} {...props}
/> />
) );
} }
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) { function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-header" data-slot="item-header"
className={cn( className={cn(
'flex basis-full items-center justify-between gap-2', "flex basis-full items-center justify-between gap-2",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) { function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-footer" data-slot="item-footer"
className={cn( className={cn(
'flex basis-full items-center justify-between gap-2', "flex basis-full items-center justify-between gap-2",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -190,4 +190,4 @@ export {
ItemDescription, ItemDescription,
ItemHeader, ItemHeader,
ItemFooter, ItemFooter,
} };

View File

@@ -1,28 +1,28 @@
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return ( return (
<kbd <kbd
data-slot="kbd" data-slot="kbd"
className={cn( className={cn(
'bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none', "bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3", "[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10', "[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<kbd <kbd
data-slot="kbd-group" data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)} className={cn("inline-flex items-center gap-1", className)}
{...props} {...props}
/> />
) );
} }
export { Kbd, KbdGroup } export { Kbd, KbdGroup };

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as LabelPrimitive from '@radix-ui/react-label' import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Label({ function Label({
className, className,
@@ -13,12 +13,12 @@ function Label({
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( 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', "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, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@@ -1,10 +1,10 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as MenubarPrimitive from '@radix-ui/react-menubar' import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Menubar({ function Menubar({
className, className,
@@ -14,30 +14,30 @@ function Menubar({
<MenubarPrimitive.Root <MenubarPrimitive.Root
data-slot="menubar" data-slot="menubar"
className={cn( className={cn(
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs', "bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MenubarMenu({ function MenubarMenu({
...props ...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) { }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} /> return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
} }
function MenubarGroup({ function MenubarGroup({
...props ...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) { }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} /> return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
} }
function MenubarPortal({ function MenubarPortal({
...props ...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) { }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} /> return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
} }
function MenubarRadioGroup({ function MenubarRadioGroup({
@@ -45,7 +45,7 @@ function MenubarRadioGroup({
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) { }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return ( return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} /> <MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
) );
} }
function MenubarTrigger({ function MenubarTrigger({
@@ -56,17 +56,17 @@ function MenubarTrigger({
<MenubarPrimitive.Trigger <MenubarPrimitive.Trigger
data-slot="menubar-trigger" data-slot="menubar-trigger"
className={cn( className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none', "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MenubarContent({ function MenubarContent({
className, className,
align = 'start', align = "start",
alignOffset = -4, alignOffset = -4,
sideOffset = 8, sideOffset = 8,
...props ...props
@@ -79,23 +79,23 @@ function MenubarContent({
alignOffset={alignOffset} alignOffset={alignOffset}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className, className,
)} )}
{...props} {...props}
/> />
</MenubarPortal> </MenubarPortal>
) );
} }
function MenubarItem({ function MenubarItem({
className, className,
inset, inset,
variant = 'default', variant = "default",
...props ...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & { }: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: 'default' | 'destructive' variant?: "default" | "destructive";
}) { }) {
return ( return (
<MenubarPrimitive.Item <MenubarPrimitive.Item
@@ -108,7 +108,7 @@ function MenubarItem({
)} )}
{...props} {...props}
/> />
) );
} }
function MenubarCheckboxItem({ function MenubarCheckboxItem({
@@ -134,7 +134,7 @@ function MenubarCheckboxItem({
</span> </span>
{children} {children}
</MenubarPrimitive.CheckboxItem> </MenubarPrimitive.CheckboxItem>
) );
} }
function MenubarRadioItem({ function MenubarRadioItem({
@@ -158,7 +158,7 @@ function MenubarRadioItem({
</span> </span>
{children} {children}
</MenubarPrimitive.RadioItem> </MenubarPrimitive.RadioItem>
) );
} }
function MenubarLabel({ function MenubarLabel({
@@ -166,19 +166,19 @@ function MenubarLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & { }: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenubarPrimitive.Label <MenubarPrimitive.Label
data-slot="menubar-label" data-slot="menubar-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MenubarSeparator({ function MenubarSeparator({
@@ -188,32 +188,32 @@ function MenubarSeparator({
return ( return (
<MenubarPrimitive.Separator <MenubarPrimitive.Separator
data-slot="menubar-separator" data-slot="menubar-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function MenubarShortcut({ function MenubarShortcut({
className, className,
...props ...props
}: React.ComponentProps<'span'>) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="menubar-shortcut" data-slot="menubar-shortcut"
className={cn( className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest', "text-muted-foreground ml-auto text-xs tracking-widest",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function MenubarSub({ function MenubarSub({
...props ...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) { }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} /> return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
} }
function MenubarSubTrigger({ function MenubarSubTrigger({
@@ -222,14 +222,14 @@ function MenubarSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & { }: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenubarPrimitive.SubTrigger <MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger" data-slot="menubar-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8', "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className, className,
)} )}
{...props} {...props}
@@ -237,7 +237,7 @@ function MenubarSubTrigger({
{children} {children}
<ChevronRightIcon className="ml-auto h-4 w-4" /> <ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger> </MenubarPrimitive.SubTrigger>
) );
} }
function MenubarSubContent({ function MenubarSubContent({
@@ -248,12 +248,12 @@ function MenubarSubContent({
<MenubarPrimitive.SubContent <MenubarPrimitive.SubContent
data-slot="menubar-sub-content" data-slot="menubar-sub-content"
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -273,4 +273,4 @@ export {
MenubarSub, MenubarSub,
MenubarSubTrigger, MenubarSubTrigger,
MenubarSubContent, MenubarSubContent,
} };

View File

@@ -1,9 +1,9 @@
import * as React from 'react' import * as React from "react";
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu' import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from 'class-variance-authority' import { cva } from "class-variance-authority";
import { ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function NavigationMenu({ function NavigationMenu({
className, className,
@@ -11,14 +11,14 @@ function NavigationMenu({
viewport = true, viewport = true,
...props ...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & { }: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean viewport?: boolean;
}) { }) {
return ( return (
<NavigationMenuPrimitive.Root <NavigationMenuPrimitive.Root
data-slot="navigation-menu" data-slot="navigation-menu"
data-viewport={viewport} data-viewport={viewport}
className={cn( className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center', "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className, className,
)} )}
{...props} {...props}
@@ -26,7 +26,7 @@ function NavigationMenu({
{children} {children}
{viewport && <NavigationMenuViewport />} {viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root> </NavigationMenuPrimitive.Root>
) );
} }
function NavigationMenuList({ function NavigationMenuList({
@@ -37,12 +37,12 @@ function NavigationMenuList({
<NavigationMenuPrimitive.List <NavigationMenuPrimitive.List
data-slot="navigation-menu-list" data-slot="navigation-menu-list"
className={cn( className={cn(
'group flex flex-1 list-none items-center justify-center gap-1', "group flex flex-1 list-none items-center justify-center gap-1",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuItem({ function NavigationMenuItem({
@@ -52,15 +52,15 @@ function NavigationMenuItem({
return ( return (
<NavigationMenuPrimitive.Item <NavigationMenuPrimitive.Item
data-slot="navigation-menu-item" data-slot="navigation-menu-item"
className={cn('relative', className)} className={cn("relative", className)}
{...props} {...props}
/> />
) );
} }
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1', "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
) );
function NavigationMenuTrigger({ function NavigationMenuTrigger({
className, className,
@@ -70,16 +70,16 @@ function NavigationMenuTrigger({
return ( return (
<NavigationMenuPrimitive.Trigger <NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger" data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), 'group', className)} className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props} {...props}
> >
{children}{' '} {children}{" "}
<ChevronDownIcon <ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180" className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
</NavigationMenuPrimitive.Trigger> </NavigationMenuPrimitive.Trigger>
) );
} }
function NavigationMenuContent({ function NavigationMenuContent({
@@ -90,13 +90,13 @@ function NavigationMenuContent({
<NavigationMenuPrimitive.Content <NavigationMenuPrimitive.Content
data-slot="navigation-menu-content" data-slot="navigation-menu-content"
className={cn( className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto', "data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none', "group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuViewport({ function NavigationMenuViewport({
@@ -105,18 +105,18 @@ function NavigationMenuViewport({
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) { }: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return ( return (
<div <div
className={'absolute top-full left-0 isolate z-50 flex justify-center'} className={"absolute top-full left-0 isolate z-50 flex justify-center"}
> >
<NavigationMenuPrimitive.Viewport <NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport" data-slot="navigation-menu-viewport"
className={cn( className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]', "origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className, className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function NavigationMenuLink({ function NavigationMenuLink({
@@ -132,7 +132,7 @@ function NavigationMenuLink({
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuIndicator({ function NavigationMenuIndicator({
@@ -143,14 +143,14 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Indicator <NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator" data-slot="navigation-menu-indicator"
className={cn( className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden', "data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className, className,
)} )}
{...props} {...props}
> >
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" /> <div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator> </NavigationMenuPrimitive.Indicator>
) );
} }
export { export {
@@ -163,4 +163,4 @@ export {
NavigationMenuIndicator, NavigationMenuIndicator,
NavigationMenuViewport, NavigationMenuViewport,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} };

View File

@@ -1,68 +1,68 @@
import * as React from 'react' import * as React from "react";
import { import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
} from 'lucide-react' } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Button, buttonVariants } from '@/components/ui/button' import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return ( return (
<nav <nav
role="navigation" role="navigation"
aria-label="pagination" aria-label="pagination"
data-slot="pagination" data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)} className={cn("mx-auto flex w-full justify-center", className)}
{...props} {...props}
/> />
) );
} }
function PaginationContent({ function PaginationContent({
className, className,
...props ...props
}: React.ComponentProps<'ul'>) { }: React.ComponentProps<"ul">) {
return ( return (
<ul <ul
data-slot="pagination-content" data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)} className={cn("flex flex-row items-center gap-1", className)}
{...props} {...props}
/> />
) );
} }
function PaginationItem({ ...props }: React.ComponentProps<'li'>) { function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} /> return <li data-slot="pagination-item" {...props} />;
} }
type PaginationLinkProps = { type PaginationLinkProps = {
isActive?: boolean isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> & } & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<'a'> React.ComponentProps<"a">;
function PaginationLink({ function PaginationLink({
className, className,
isActive, isActive,
size = 'icon', size = "icon",
...props ...props
}: PaginationLinkProps) { }: PaginationLinkProps) {
return ( return (
<a <a
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? "page" : undefined}
data-slot="pagination-link" data-slot="pagination-link"
data-active={isActive} data-active={isActive}
className={cn( className={cn(
buttonVariants({ buttonVariants({
variant: isActive ? 'outline' : 'ghost', variant: isActive ? "outline" : "ghost",
size, size,
}), }),
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function PaginationPrevious({ function PaginationPrevious({
@@ -73,13 +73,13 @@ function PaginationPrevious({
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label="Go to previous page"
size="default" size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)} className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props} {...props}
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
<span className="hidden sm:block">Previous</span> <span className="hidden sm:block">Previous</span>
</PaginationLink> </PaginationLink>
) );
} }
function PaginationNext({ function PaginationNext({
@@ -90,30 +90,30 @@ function PaginationNext({
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label="Go to next page"
size="default" size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)} className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props} {...props}
> >
<span className="hidden sm:block">Next</span> <span className="hidden sm:block">Next</span>
<ChevronRightIcon /> <ChevronRightIcon />
</PaginationLink> </PaginationLink>
) );
} }
function PaginationEllipsis({ function PaginationEllipsis({
className, className,
...props ...props
}: React.ComponentProps<'span'>) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
aria-hidden aria-hidden
data-slot="pagination-ellipsis" data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)} className={cn("flex size-9 items-center justify-center", className)}
{...props} {...props}
> >
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span> <span className="sr-only">More pages</span>
</span> </span>
) );
} }
export { export {
@@ -124,4 +124,4 @@ export {
PaginationPrevious, PaginationPrevious,
PaginationNext, PaginationNext,
PaginationEllipsis, PaginationEllipsis,
} };

View File

@@ -1,25 +1,25 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as PopoverPrimitive from '@radix-ui/react-popover' import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
className, className,
align = 'center', align = "center",
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) { }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
@@ -30,19 +30,19 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className, className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as ProgressPrimitive from '@radix-ui/react-progress' import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Progress({ function Progress({
className, className,
@@ -14,7 +14,7 @@ function Progress({
<ProgressPrimitive.Root <ProgressPrimitive.Root
data-slot="progress" data-slot="progress"
className={cn( className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className, className,
)} )}
{...props} {...props}
@@ -25,7 +25,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };

View File

@@ -1,10 +1,10 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from 'lucide-react' import { CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function RadioGroup({ function RadioGroup({
className, className,
@@ -13,10 +13,10 @@ function RadioGroup({
return ( return (
<RadioGroupPrimitive.Root <RadioGroupPrimitive.Root
data-slot="radio-group" data-slot="radio-group"
className={cn('grid gap-3', className)} className={cn("grid gap-3", className)}
{...props} {...props}
/> />
) );
} }
function RadioGroupItem({ function RadioGroupItem({
@@ -27,7 +27,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
data-slot="radio-group-item" data-slot="radio-group-item"
className={cn( className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ function RadioGroupItem({
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" /> <CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) );
} }
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem };

View File

@@ -1,10 +1,10 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import { GripVerticalIcon } from 'lucide-react' import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from 'react-resizable-panels' import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
@@ -14,18 +14,18 @@ function ResizablePanelGroup({
<ResizablePrimitive.PanelGroup <ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn( className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col', "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function ResizablePanel({ function ResizablePanel({
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) { }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} /> return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
} }
function ResizableHandle({ function ResizableHandle({
@@ -33,13 +33,13 @@ function ResizableHandle({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean withHandle?: boolean;
}) { }) {
return ( return (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90', "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className, className,
)} )}
{...props} {...props}
@@ -50,7 +50,7 @@ function ResizableHandle({
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizablePrimitive.PanelResizeHandle>
) );
} }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function ScrollArea({ function ScrollArea({
className, className,
@@ -13,7 +13,7 @@ function ScrollArea({
return ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="scroll-area" data-slot="scroll-area"
className={cn('relative', className)} className={cn("relative", className)}
{...props} {...props}
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
@@ -25,12 +25,12 @@ function ScrollArea({
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
) );
} }
function ScrollBar({ function ScrollBar({
className, className,
orientation = 'vertical', orientation = "vertical",
...props ...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) { }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return ( return (
@@ -38,11 +38,11 @@ function ScrollBar({
data-slot="scroll-area-scrollbar" data-slot="scroll-area-scrollbar"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'flex touch-none p-px transition-colors select-none', "flex touch-none p-px transition-colors select-none",
orientation === 'vertical' && orientation === "vertical" &&
'h-full w-2.5 border-l border-l-transparent', "h-full w-2.5 border-l border-l-transparent",
orientation === 'horizontal' && orientation === "horizontal" &&
'h-2.5 flex-col border-t border-t-transparent', "h-2.5 flex-col border-t border-t-transparent",
className, className,
)} )}
{...props} {...props}
@@ -52,7 +52,7 @@ function ScrollBar({
className="bg-border relative flex-1 rounded-full" className="bg-border relative flex-1 rounded-full"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
) );
} }
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar };

View File

@@ -1,36 +1,36 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as SelectPrimitive from '@radix-ui/react-select' import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react' import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
className, className,
size = 'default', size = "default",
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default' size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -47,13 +47,13 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
className, className,
children, children,
position = 'popper', position = "popper",
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
@@ -61,9 +61,9 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md', "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === 'popper' && position === "popper" &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
)} )}
position={position} position={position}
@@ -72,9 +72,9 @@ function SelectContent({
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
'p-1', "p-1",
position === 'popper' && position === "popper" &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1', "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@@ -82,7 +82,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@@ -92,10 +92,10 @@ function SelectLabel({
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -119,7 +119,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -129,10 +129,10 @@ function SelectSeparator({
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -143,14 +143,14 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
'flex cursor-default items-center justify-center py-1', "flex cursor-default items-center justify-center py-1",
className, className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -161,14 +161,14 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
'flex cursor-default items-center justify-center py-1', "flex cursor-default items-center justify-center py-1",
className, className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -182,4 +182,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };

View File

@@ -1,13 +1,13 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as SeparatorPrimitive from '@radix-ui/react-separator' import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Separator({ function Separator({
className, className,
orientation = 'horizontal', orientation = "horizontal",
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@@ -17,12 +17,12 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@@ -1,31 +1,31 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as SheetPrimitive from '@radix-ui/react-dialog' import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from 'lucide-react' import { XIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@@ -36,21 +36,21 @@ function SheetOverlay({
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
className, className,
children, children,
side = 'right', side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left' side?: "top" | "right" | "bottom" | "left";
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -58,15 +58,15 @@ function SheetContent({
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === 'right' && side === "right" &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === 'left' && side === "left" &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === 'top' && side === "top" &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === 'bottom' && side === "bottom" &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className, className,
)} )}
{...props} {...props}
@@ -78,27 +78,27 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-footer" data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@@ -108,10 +108,10 @@ function SheetTitle({
return ( return (
<SheetPrimitive.Title <SheetPrimitive.Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -121,10 +121,10 @@ function SheetDescription({
return ( return (
<SheetPrimitive.Description <SheetPrimitive.Description
data-slot="sheet-description" data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -136,4 +136,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@@ -1,56 +1,56 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot";
import { cva, VariantProps } from 'class-variance-authority' import { cva, VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from 'lucide-react' import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Separator } from '@/components/ui/separator' import { Separator } from "@/components/ui/separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from "@/components/ui/sheet";
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from "@/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = 'sidebar_state' const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem' const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = '18rem' const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = '3rem' const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = 'b' const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: 'expanded' | 'collapsed' state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.') throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
function SidebarProvider({ function SidebarProvider({
@@ -61,37 +61,37 @@ function SidebarProvider({
style, style,
children, children,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open], [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@@ -100,18 +100,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
}
} }
};
window.addEventListener('keydown', handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed' const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@@ -124,7 +124,7 @@ function SidebarProvider({
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@@ -133,13 +133,13 @@ function SidebarProvider({
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
style={ style={
{ {
'--sidebar-width': SIDEBAR_WIDTH, "--sidebar-width": SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style, ...style,
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className, className,
)} )}
{...props} {...props}
@@ -148,36 +148,36 @@ function SidebarProvider({
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
side = 'left', side = "left",
variant = 'sidebar', variant = "sidebar",
collapsible = 'offcanvas', collapsible = "offcanvas",
className, className,
children, children,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
side?: 'left' | 'right' side?: "left" | "right";
variant?: 'sidebar' | 'floating' | 'inset' variant?: "sidebar" | "floating" | "inset";
collapsible?: 'offcanvas' | 'icon' | 'none' collapsible?: "offcanvas" | "icon" | "none";
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') { if (collapsible === "none") {
return ( return (
<div <div
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@@ -190,7 +190,7 @@ function Sidebar({
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={ style={
{ {
'--sidebar-width': SIDEBAR_WIDTH_MOBILE, "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties } as React.CSSProperties
} }
side={side} side={side}
@@ -202,14 +202,14 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
<div <div
className="group peer text-sidebar-foreground hidden md:block" className="group peer text-sidebar-foreground hidden md:block"
data-state={state} data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''} data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant} data-variant={variant}
data-side={side} data-side={side}
data-slot="sidebar" data-slot="sidebar"
@@ -218,25 +218,25 @@ function Sidebar({
<div <div
data-slot="sidebar-gap" data-slot="sidebar-gap"
className={cn( className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
'group-data-[collapsible=offcanvas]:w-0', "group-data-[collapsible=offcanvas]:w-0",
'group-data-[side=right]:rotate-180', "group-data-[side=right]:rotate-180",
variant === 'floating' || variant === 'inset' variant === "floating" || variant === "inset"
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)} )}
/> />
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
className={cn( className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === 'left' side === "left"
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants. // Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset' variant === "floating" || variant === "inset"
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className, className,
)} )}
{...props} {...props}
@@ -250,7 +250,7 @@ function Sidebar({
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({
@@ -258,7 +258,7 @@ function SidebarTrigger({
onClick, onClick,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@@ -266,21 +266,21 @@ function SidebarTrigger({
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn('size-7', className)} className={cn("size-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@@ -291,31 +291,31 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full', "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) { function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return ( return (
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
'bg-background relative flex w-full flex-1 flex-col', "bg-background relative flex w-full flex-1 flex-col",
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({
@@ -326,32 +326,32 @@ function SidebarInput({
<Input <Input
data-slot="sidebar-input" data-slot="sidebar-input"
data-sidebar="input" data-sidebar="input"
className={cn('bg-background h-8 w-full shadow-none', className)} className={cn("bg-background h-8 w-full shadow-none", className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) { function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-header" data-slot="sidebar-header"
data-sidebar="header" data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) { function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-footer" data-slot="sidebar-footer"
data-sidebar="footer" data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({
@@ -362,154 +362,154 @@ function SidebarSeparator({
<Separator <Separator
data-slot="sidebar-separator" data-slot="sidebar-separator"
data-sidebar="separator" data-sidebar="separator"
className={cn('bg-sidebar-border mx-2 w-auto', className)} className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) { function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) { function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-group" data-slot="sidebar-group"
data-sidebar="group" data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
className, className,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) { }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div' const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
data-slot="sidebar-group-label" data-slot="sidebar-group-label"
data-sidebar="group-label" data-sidebar="group-label"
className={cn( className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupAction({ function SidebarGroupAction({
className, className,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) { }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
data-slot="sidebar-group-action" data-slot="sidebar-group-action"
data-sidebar="group-action" data-sidebar="group-action"
className={cn( className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden', "after:absolute after:-inset-2 md:after:hidden",
'group-data-[collapsible=icon]:hidden', "group-data-[collapsible=icon]:hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupContent({ function SidebarGroupContent({
className, className,
...props ...props
}: React.ComponentProps<'div'>) { }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-group-content" data-slot="sidebar-group-content"
data-sidebar="group-content" data-sidebar="group-content"
className={cn('w-full text-sm', className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) { function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return ( return (
<ul <ul
data-slot="sidebar-menu" data-slot="sidebar-menu"
data-sidebar="menu" data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)} className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="sidebar-menu-item" data-slot="sidebar-menu-item"
data-sidebar="menu-item" data-sidebar="menu-item"
className={cn('group/menu-item relative', className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline: outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]', "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
}, },
size: { size: {
default: 'h-8 text-sm', default: "h-8 text-sm",
sm: 'h-7 text-xs', sm: "h-7 text-xs",
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!', lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) );
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, asChild = false,
isActive = false, isActive = false,
variant = 'default', variant = "default",
size = 'default', size = "default",
tooltip, tooltip,
className, className,
...props ...props
}: React.ComponentProps<'button'> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@@ -520,16 +520,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === 'string') { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@@ -538,11 +538,11 @@ function SidebarMenuButton({
<TooltipContent <TooltipContent
side="right" side="right"
align="center" align="center"
hidden={state !== 'collapsed' || isMobile} hidden={state !== "collapsed" || isMobile}
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@@ -550,72 +550,72 @@ function SidebarMenuAction({
asChild = false, asChild = false,
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<'button'> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
data-slot="sidebar-menu-action" data-slot="sidebar-menu-action"
data-sidebar="menu-action" data-sidebar="menu-action"
className={cn( className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden', "after:absolute after:-inset-2 md:after:hidden",
'peer-data-[size=sm]/menu-button:top-1', "peer-data-[size=sm]/menu-button:top-1",
'peer-data-[size=default]/menu-button:top-1.5', "peer-data-[size=default]/menu-button:top-1.5",
'peer-data-[size=lg]/menu-button:top-2.5', "peer-data-[size=lg]/menu-button:top-2.5",
'group-data-[collapsible=icon]:hidden', "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuBadge({ function SidebarMenuBadge({
className, className,
...props ...props
}: React.ComponentProps<'div'>) { }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sidebar-menu-badge" data-slot="sidebar-menu-badge"
data-sidebar="menu-badge" data-sidebar="menu-badge"
className={cn( className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none', "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
'peer-data-[size=sm]/menu-button:top-1', "peer-data-[size=sm]/menu-button:top-1",
'peer-data-[size=default]/menu-button:top-1.5', "peer-data-[size=default]/menu-button:top-1.5",
'peer-data-[size=lg]/menu-button:top-2.5', "peer-data-[size=lg]/menu-button:top-2.5",
'group-data-[collapsible=icon]:hidden', "group-data-[collapsible=icon]:hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
className, className,
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<'div'> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
data-slot="sidebar-menu-skeleton" data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton" data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props} {...props}
> >
{showIcon && ( {showIcon && (
@@ -629,55 +629,55 @@ function SidebarMenuSkeleton({
data-sidebar="menu-skeleton-text" data-sidebar="menu-skeleton-text"
style={ style={
{ {
'--skeleton-width': width, "--skeleton-width": width,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return ( return (
<ul <ul
data-slot="sidebar-menu-sub" data-slot="sidebar-menu-sub"
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5', "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
'group-data-[collapsible=icon]:hidden', "group-data-[collapsible=icon]:hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({
className, className,
...props ...props
}: React.ComponentProps<'li'>) { }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="sidebar-menu-sub-item" data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item" data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)} className={cn("group/menu-sub-item relative", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
asChild = false, asChild = false,
size = 'md', size = "md",
isActive = false, isActive = false,
className, className,
...props ...props
}: React.ComponentProps<'a'> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
size?: 'sm' | 'md' size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
}) { }) {
const Comp = asChild ? Slot : 'a' const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@@ -686,16 +686,16 @@ function SidebarMenuSubButton({
data-size={size} data-size={size}
data-active={isActive} data-active={isActive}
className={cn( className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === 'sm' && 'text-xs', size === "sm" && "text-xs",
size === 'md' && 'text-sm', size === "md" && "text-sm",
'group-data-[collapsible=icon]:hidden', "group-data-[collapsible=icon]:hidden",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -723,4 +723,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };

View File

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

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as SliderPrimitive from '@radix-ui/react-slider' import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Slider({ function Slider({
className, className,
@@ -21,7 +21,7 @@ function Slider({
? defaultValue ? defaultValue
: [min, max], : [min, max],
[value, defaultValue, min, max], [value, defaultValue, min, max],
) );
return ( return (
<SliderPrimitive.Root <SliderPrimitive.Root
@@ -31,7 +31,7 @@ function Slider({
min={min} min={min}
max={max} max={max}
className={cn( className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col', "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className, className,
)} )}
{...props} {...props}
@@ -39,13 +39,13 @@ function Slider({
<SliderPrimitive.Track <SliderPrimitive.Track
data-slot="slider-track" data-slot="slider-track"
className={ className={
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5' "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
} }
> >
<SliderPrimitive.Range <SliderPrimitive.Range
data-slot="slider-range" data-slot="slider-range"
className={ className={
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full' "bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
} }
/> />
</SliderPrimitive.Track> </SliderPrimitive.Track>
@@ -57,7 +57,7 @@ function Slider({
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>
) );
} }
export { Slider } export { Slider };

View File

@@ -1,25 +1,25 @@
'use client' "use client";
import { useTheme } from 'next-themes' import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from 'sonner' import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps['theme']} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
style={ style={
{ {
'--normal-bg': 'var(--popover)', "--normal-bg": "var(--popover)",
'--normal-text': 'var(--popover-foreground)', "--normal-text": "var(--popover-foreground)",
'--normal-border': 'var(--border)', "--normal-border": "var(--border)",
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

View File

@@ -1,16 +1,16 @@
import { Loader2Icon } from 'lucide-react' import { Loader2Icon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) { function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return ( return (
<Loader2Icon <Loader2Icon
role="status" role="status"
aria-label="Loading" aria-label="Loading"
className={cn('size-4 animate-spin', className)} className={cn("size-4 animate-spin", className)}
{...props} {...props}
/> />
) );
} }
export { Spinner } export { Spinner };

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as SwitchPrimitive from '@radix-ui/react-switch' import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Switch({ function Switch({
className, className,
@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
className={cn( className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -21,11 +21,11 @@ function Switch({
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className={ className={
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0' "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
} }
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
) );
} }
export { Switch } export { Switch };

View File

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

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as TabsPrimitive from '@radix-ui/react-tabs' import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function Tabs({ function Tabs({
className, className,
@@ -12,10 +12,10 @@ function Tabs({
return ( return (
<TabsPrimitive.Root <TabsPrimitive.Root
data-slot="tabs" data-slot="tabs"
className={cn('flex flex-col gap-2', className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
function TabsList({ function TabsList({
@@ -26,12 +26,12 @@ function TabsList({
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className, className,
)} )}
{...props} {...props}
/> />
) );
} }
function TabsTrigger({ function TabsTrigger({
@@ -47,7 +47,7 @@ function TabsTrigger({
)} )}
{...props} {...props}
/> />
) );
} }
function TabsContent({ function TabsContent({
@@ -57,10 +57,10 @@ function TabsContent({
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
data-slot="tabs-content" data-slot="tabs-content"
className={cn('flex-1 outline-none', className)} className={cn("flex-1 outline-none", className)}
{...props} {...props}
/> />
) );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

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

View File

@@ -1,13 +1,13 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as ToastPrimitives from '@radix-ui/react-toast' import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { X } from 'lucide-react' import { X } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
@@ -16,29 +16,29 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]', "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className, className,
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{ {
variants: { variants: {
variant: { variant: {
default: 'border bg-background text-foreground', default: "border bg-background text-foreground",
destructive: destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground', "destructive group border-destructive bg-destructive text-destructive-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
@@ -51,9 +51,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
@@ -62,13 +62,13 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className, className,
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className, className,
)} )}
toast-close="" toast-close=""
@@ -85,8 +85,8 @@ const ToastClose = React.forwardRef<
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
@@ -94,11 +94,11 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn('text-sm font-semibold', className)} className={cn("text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
@@ -106,15 +106,15 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn('text-sm opacity-90', className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)) ));
ToastDescription.displayName = ToastPrimitives.Description.displayName ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction> type ToastActionElement = React.ReactElement<typeof ToastAction>;
export { export {
type ToastProps, type ToastProps,
@@ -126,4 +126,4 @@ export {
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
} };

View File

@@ -1,6 +1,6 @@
'use client' "use client";
import { useToast } from '@/hooks/use-toast' import { useToast } from "@/hooks/use-toast";
import { import {
Toast, Toast,
ToastClose, ToastClose,
@@ -8,10 +8,10 @@ import {
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport,
} from '@/components/ui/toast' } from "@/components/ui/toast";
export function Toaster() { export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
@@ -27,9 +27,9 @@ export function Toaster() {
{action} {action}
<ToastClose /> <ToastClose />
</Toast> </Toast>
) );
})} })}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) );
} }

View File

@@ -1,18 +1,18 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group' import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from 'class-variance-authority' import { type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { toggleVariants } from '@/components/ui/toggle' import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext< const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>({ >({
size: 'default', size: "default",
variant: 'default', variant: "default",
}) });
function ToggleGroup({ function ToggleGroup({
className, className,
@@ -28,7 +28,7 @@ function ToggleGroup({
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn( className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs', "group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className, className,
)} )}
{...props} {...props}
@@ -37,7 +37,7 @@ function ToggleGroup({
{children} {children}
</ToggleGroupContext.Provider> </ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>
) );
} }
function ToggleGroupItem({ function ToggleGroupItem({
@@ -48,7 +48,7 @@ function ToggleGroupItem({
...props ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) { VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext) const context = React.useContext(ToggleGroupContext);
return ( return (
<ToggleGroupPrimitive.Item <ToggleGroupPrimitive.Item
@@ -60,14 +60,14 @@ function ToggleGroupItem({
variant: context.variant || variant, variant: context.variant || variant,
size: context.size || size, size: context.size || size,
}), }),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l', "min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</ToggleGroupPrimitive.Item> </ToggleGroupPrimitive.Item>
) );
} }
export { ToggleGroup, ToggleGroupItem } export { ToggleGroup, ToggleGroupItem };

View File

@@ -1,32 +1,32 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as TogglePrimitive from '@radix-ui/react-toggle' import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const toggleVariants = cva( const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-transparent', default: "bg-transparent",
outline: outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
}, },
size: { size: {
default: 'h-9 px-2 min-w-9', default: "h-9 px-2 min-w-9",
sm: 'h-8 px-1.5 min-w-8', sm: "h-8 px-1.5 min-w-8",
lg: 'h-10 px-2.5 min-w-10', lg: "h-10 px-2.5 min-w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) );
function Toggle({ function Toggle({
className, className,
@@ -41,7 +41,7 @@ function Toggle({
className={cn(toggleVariants({ variant, size, className }))} className={cn(toggleVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Toggle, toggleVariants } export { Toggle, toggleVariants };

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import * as React from 'react' import * as React from "react";
import * as TooltipPrimitive from '@radix-ui/react-tooltip' import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) );
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -46,7 +46,7 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance', "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className, className,
)} )}
{...props} {...props}
@@ -55,7 +55,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,19 +1,21 @@
import * as React from 'react' import * as React from "react";
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
} };
mql.addEventListener('change', onChange) mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange) return () => mql.removeEventListener("change", onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }

View File

@@ -1,103 +1,103 @@
'use client' "use client";
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from 'react' import * as React from "react";
import type { ToastActionElement, ToastProps } from '@/components/ui/toast' import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1 const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: 'ADD_TOAST', ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: 'UPDATE_TOAST', UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: 'DISMISS_TOAST', DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: 'REMOVE_TOAST', REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType['ADD_TOAST'] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType['UPDATE_TOAST'] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType['DISMISS_TOAST'] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast['id'] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType['REMOVE_TOAST'] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast['id'] toastId?: ToasterToast["id"];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: 'REMOVE_TOAST', type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case 'ADD_TOAST': case "ADD_TOAST":
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case 'UPDATE_TOAST': case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t, t.id === action.toast.id ? { ...t, ...action.toast } : t,
), ),
} };
case 'DISMISS_TOAST': { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
@@ -110,82 +110,82 @@ export const reducer = (state: State, action: Action): State => {
} }
: t, : t,
), ),
};
} }
} case "REMOVE_TOAST":
case 'REMOVE_TOAST':
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
} }
} };
}
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, 'id'> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: 'UPDATE_TOAST', type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ dispatch({
type: 'ADD_TOAST', type: "ADD_TOAST",
toast: { toast: {
...props, ...props,
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
} };
} }
export { useToast, toast } export { useToast, toast };

View File

@@ -1,36 +1,36 @@
import nextPlugin from '@next/eslint-plugin-next' import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from 'eslint-plugin-react' import reactPlugin from "eslint-plugin-react";
import hooksPlugin from 'eslint-plugin-react-hooks' import hooksPlugin from "eslint-plugin-react-hooks";
import tseslint from 'typescript-eslint' import tseslint from "typescript-eslint";
export default [ export default [
{ {
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**'], ignores: ["node_modules/**", ".next/**", "out/**", "build/**"],
}, },
...tseslint.configs.recommended, ...tseslint.configs.recommended,
{ {
files: ['**/*.{js,jsx,ts,tsx}'], files: ["**/*.{js,jsx,ts,tsx}"],
plugins: { plugins: {
'@next/next': nextPlugin, "@next/next": nextPlugin,
react: reactPlugin, react: reactPlugin,
'react-hooks': hooksPlugin, "react-hooks": hooksPlugin,
}, },
rules: { rules: {
...nextPlugin.configs.recommended.rules, ...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules, ...nextPlugin.configs["core-web-vitals"].rules,
'react/react-in-jsx-scope': 'off', "react/react-in-jsx-scope": "off",
'react-hooks/rules-of-hooks': 'error', "react-hooks/rules-of-hooks": "error",
'react-hooks/exhaustive-deps': 'warn', "react-hooks/exhaustive-deps": "warn",
'@typescript-eslint/no-unused-vars': [ "@typescript-eslint/no-unused-vars": [
'warn', "warn",
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
], ],
'@typescript-eslint/no-explicit-any': 'warn', "@typescript-eslint/no-explicit-any": "warn",
}, },
settings: { settings: {
react: { react: {
version: 'detect', version: "detect",
}, },
}, },
}, },
] ];

View File

@@ -1,19 +1,21 @@
import * as React from 'react' import * as React from "react";
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
} };
mql.addEventListener('change', onChange) mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange) return () => mql.removeEventListener("change", onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }

View File

@@ -1,103 +1,103 @@
'use client' "use client";
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from 'react' import * as React from "react";
import type { ToastActionElement, ToastProps } from '@/components/ui/toast' import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1 const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: 'ADD_TOAST', ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: 'UPDATE_TOAST', UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: 'DISMISS_TOAST', DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: 'REMOVE_TOAST', REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType['ADD_TOAST'] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType['UPDATE_TOAST'] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType['DISMISS_TOAST'] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast['id'] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType['REMOVE_TOAST'] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast['id'] toastId?: ToasterToast["id"];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: 'REMOVE_TOAST', type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case 'ADD_TOAST': case "ADD_TOAST":
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case 'UPDATE_TOAST': case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t, t.id === action.toast.id ? { ...t, ...action.toast } : t,
), ),
} };
case 'DISMISS_TOAST': { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
@@ -110,82 +110,82 @@ export const reducer = (state: State, action: Action): State => {
} }
: t, : t,
), ),
};
} }
} case "REMOVE_TOAST":
case 'REMOVE_TOAST':
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
} }
} };
}
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, 'id'> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: 'UPDATE_TOAST', type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ dispatch({
type: 'ADD_TOAST', type: "ADD_TOAST",
toast: { toast: {
...props, ...props,
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
} };
} }
export { useToast, toast } export { useToast, toast };

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,68 @@
"use client" "use client";
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback } from "react";
import type { BankingData } from "./types" import type { BankingData } from "./types";
import { loadData } from "./store-db" import { loadData } from "./store-db";
export function useBankingData() { export function useBankingData() {
const [data, setData] = useState<BankingData | null>(null) const [data, setData] = useState<BankingData | null>(null);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
setIsLoading(true) setIsLoading(true);
setError(null) setError(null);
const fetchedData = await loadData() const fetchedData = await loadData();
setData(fetchedData) setData(fetchedData);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load data")) setError(err instanceof Error ? err : new Error("Failed to load data"));
console.error("Error loading banking data:", err) console.error("Error loading banking data:", err);
} finally { } finally {
setIsLoading(false) setIsLoading(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
fetchData() fetchData();
}, [fetchData]) }, [fetchData]);
const refresh = useCallback(() => { const refresh = useCallback(() => {
fetchData() fetchData();
}, [fetchData]) }, [fetchData]);
const update = useCallback((newData: BankingData) => { const update = useCallback((newData: BankingData) => {
// Optimistic update - the actual save happens in individual operations // Optimistic update - the actual save happens in individual operations
setData(newData) setData(newData);
}, []) }, []);
return { data, isLoading, error, refresh, update } return { data, isLoading, error, refresh, update };
} }
export function useLocalStorage<T>(key: string, initialValue: T) { export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(initialValue) const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => { useEffect(() => {
try { try {
const item = window.localStorage.getItem(key) const item = window.localStorage.getItem(key);
if (item) { if (item) {
setStoredValue(JSON.parse(item)) setStoredValue(JSON.parse(item));
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error);
} }
}, [key]) }, [key]);
const setValue = (value: T | ((val: T) => T)) => { const setValue = (value: T | ((val: T) => T)) => {
try { try {
const valueToStore = value instanceof Function ? value(storedValue) : value const valueToStore =
setStoredValue(valueToStore) value instanceof Function ? value(storedValue) : value;
window.localStorage.setItem(key, JSON.stringify(valueToStore)) setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) { } catch (error) {
console.error(error) console.error(error);
}
} }
};
return [storedValue, setValue] as const return [storedValue, setValue] as const;
} }

View File

@@ -1,40 +1,45 @@
import type { OFXAccount, OFXTransaction } from "./types" import type { OFXAccount, OFXTransaction } from "./types";
export function parseOFX(content: string): OFXAccount | null { export function parseOFX(content: string): OFXAccount | null {
try { try {
// Remove SGML header and clean up // Remove SGML header and clean up
const xmlStart = content.indexOf("<OFX>") const xmlStart = content.indexOf("<OFX>");
if (xmlStart === -1) return null if (xmlStart === -1) return null;
let xml = content.substring(xmlStart) let xml = content.substring(xmlStart);
// Convert SGML to XML-like format // Convert SGML to XML-like format
xml = xml.replace(/<(\w+)>([^<]+)(?=<)/g, "<$1>$2</$1>") xml = xml.replace(/<(\w+)>([^<]+)(?=<)/g, "<$1>$2</$1>");
// Extract account info // Extract account info
const bankId = extractValue(xml, "BANKID") || extractValue(xml, "ORG") || "UNKNOWN" const bankId =
const accountId = extractValue(xml, "ACCTID") || "UNKNOWN" extractValue(xml, "BANKID") || extractValue(xml, "ORG") || "UNKNOWN";
const accountType = extractValue(xml, "ACCTTYPE") || "CHECKING" const accountId = extractValue(xml, "ACCTID") || "UNKNOWN";
const balanceStr = extractValue(xml, "BALAMT") || "0" const accountType = extractValue(xml, "ACCTTYPE") || "CHECKING";
const balance = Number.parseFloat(balanceStr) const balanceStr = extractValue(xml, "BALAMT") || "0";
const balanceDate = extractValue(xml, "DTASOF") || new Date().toISOString() const balance = Number.parseFloat(balanceStr);
const currency = extractValue(xml, "CURDEF") || "EUR" const balanceDate = extractValue(xml, "DTASOF") || new Date().toISOString();
const currency = extractValue(xml, "CURDEF") || "EUR";
// Extract transactions // Extract transactions
const transactions: OFXTransaction[] = [] const transactions: OFXTransaction[] = [];
const stmtTrnRegex = /<STMTTRN>([\s\S]*?)<\/STMTTRN>/gi const stmtTrnRegex = /<STMTTRN>([\s\S]*?)<\/STMTTRN>/gi;
let match let match;
while ((match = stmtTrnRegex.exec(xml)) !== null) { while ((match = stmtTrnRegex.exec(xml)) !== null) {
const trnXml = match[1] const trnXml = match[1];
const fitId = extractValue(trnXml, "FITID") || `${Date.now()}-${Math.random()}` const fitId =
const dateStr = extractValue(trnXml, "DTPOSTED") || "" extractValue(trnXml, "FITID") || `${Date.now()}-${Math.random()}`;
const amountStr = extractValue(trnXml, "TRNAMT") || "0" const dateStr = extractValue(trnXml, "DTPOSTED") || "";
const name = extractValue(trnXml, "NAME") || extractValue(trnXml, "MEMO") || "Unknown" const amountStr = extractValue(trnXml, "TRNAMT") || "0";
const memo = extractValue(trnXml, "MEMO") const name =
const checkNum = extractValue(trnXml, "CHECKNUM") extractValue(trnXml, "NAME") ||
const type = extractValue(trnXml, "TRNTYPE") || "OTHER" extractValue(trnXml, "MEMO") ||
"Unknown";
const memo = extractValue(trnXml, "MEMO");
const checkNum = extractValue(trnXml, "CHECKNUM");
const type = extractValue(trnXml, "TRNTYPE") || "OTHER";
transactions.push({ transactions.push({
fitId, fitId,
@@ -44,7 +49,7 @@ export function parseOFX(content: string): OFXAccount | null {
memo: memo ? cleanString(memo) : undefined, memo: memo ? cleanString(memo) : undefined,
checkNum: checkNum ?? undefined, checkNum: checkNum ?? undefined,
type, type,
}) });
} }
return { return {
@@ -55,37 +60,39 @@ export function parseOFX(content: string): OFXAccount | null {
balanceDate: parseOFXDate(balanceDate), balanceDate: parseOFXDate(balanceDate),
currency, currency,
transactions, transactions,
} };
} catch (error) { } catch (error) {
console.error("Error parsing OFX:", error) console.error("Error parsing OFX:", error);
return null return null;
} }
} }
function extractValue(xml: string, tag: string): string | null { function extractValue(xml: string, tag: string): string | null {
const regex = new RegExp(`<${tag}>([^<]+)`, "i") const regex = new RegExp(`<${tag}>([^<]+)`, "i");
const match = xml.match(regex) const match = xml.match(regex);
return match ? match[1].trim() : null return match ? match[1].trim() : null;
} }
function parseOFXDate(dateStr: string): string { function parseOFXDate(dateStr: string): string {
if (!dateStr || dateStr.length < 8) return new Date().toISOString() if (!dateStr || dateStr.length < 8) return new Date().toISOString();
const year = dateStr.substring(0, 4) const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6) const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8) const day = dateStr.substring(6, 8);
return `${year}-${month}-${day}` return `${year}-${month}-${day}`;
} }
function mapAccountType(type: string): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" { function mapAccountType(
const upper = type.toUpperCase() type: string,
if (upper.includes("CHECK") || upper.includes("CURRENT")) return "CHECKING" ): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
if (upper.includes("SAV")) return "SAVINGS" const upper = type.toUpperCase();
if (upper.includes("CREDIT")) return "CREDIT_CARD" if (upper.includes("CHECK") || upper.includes("CURRENT")) return "CHECKING";
return "OTHER" if (upper.includes("SAV")) return "SAVINGS";
if (upper.includes("CREDIT")) return "CREDIT_CARD";
return "OTHER";
} }
function cleanString(str: string): string { function cleanString(str: string): string {
return str.replace(/\s+/g, " ").trim() return str.replace(/\s+/g, " ").trim();
} }

View File

@@ -1,14 +1,16 @@
import { PrismaClient } from "@prisma/client" import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined prisma: PrismaClient | undefined;
} };
export const prisma = export const prisma =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
new PrismaClient({ new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], log:
}) process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -1,27 +1,35 @@
"use client" "use client";
import type { BankingData, Account, Transaction, Folder, Category } from "./types" import type {
BankingData,
Account,
Transaction,
Folder,
Category,
} from "./types";
const API_BASE = "/api/banking" const API_BASE = "/api/banking";
export async function loadData(): Promise<BankingData> { export async function loadData(): Promise<BankingData> {
const response = await fetch(API_BASE) const response = await fetch(API_BASE);
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to load data") throw new Error("Failed to load data");
} }
return response.json() return response.json();
} }
export async function addAccount(account: Omit<Account, "id">): Promise<Account> { export async function addAccount(
account: Omit<Account, "id">,
): Promise<Account> {
const response = await fetch(`${API_BASE}/accounts`, { const response = await fetch(`${API_BASE}/accounts`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(account), body: JSON.stringify(account),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to add account") throw new Error("Failed to add account");
} }
return response.json() return response.json();
} }
export async function updateAccount(account: Account): Promise<Account> { export async function updateAccount(account: Account): Promise<Account> {
@@ -29,44 +37,48 @@ export async function updateAccount(account: Account): Promise<Account> {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(account), body: JSON.stringify(account),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to update account") throw new Error("Failed to update account");
} }
return response.json() return response.json();
} }
export async function deleteAccount(accountId: string): Promise<void> { export async function deleteAccount(accountId: string): Promise<void> {
const response = await fetch(`${API_BASE}/accounts?id=${accountId}`, { const response = await fetch(`${API_BASE}/accounts?id=${accountId}`, {
method: "DELETE", method: "DELETE",
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to delete account") throw new Error("Failed to delete account");
} }
} }
export async function addTransactions(transactions: Transaction[]): Promise<{ count: number; transactions: Transaction[] }> { export async function addTransactions(
transactions: Transaction[],
): Promise<{ count: number; transactions: Transaction[] }> {
const response = await fetch(`${API_BASE}/transactions`, { const response = await fetch(`${API_BASE}/transactions`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(transactions), body: JSON.stringify(transactions),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to add transactions") throw new Error("Failed to add transactions");
} }
return response.json() return response.json();
} }
export async function updateTransaction(transaction: Transaction): Promise<Transaction> { export async function updateTransaction(
transaction: Transaction,
): Promise<Transaction> {
const response = await fetch(`${API_BASE}/transactions`, { const response = await fetch(`${API_BASE}/transactions`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(transaction), body: JSON.stringify(transaction),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to update transaction") throw new Error("Failed to update transaction");
} }
return response.json() return response.json();
} }
export async function addFolder(folder: Omit<Folder, "id">): Promise<Folder> { export async function addFolder(folder: Omit<Folder, "id">): Promise<Folder> {
@@ -74,11 +86,11 @@ export async function addFolder(folder: Omit<Folder, "id">): Promise<Folder> {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(folder), body: JSON.stringify(folder),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to add folder") throw new Error("Failed to add folder");
} }
return response.json() return response.json();
} }
export async function updateFolder(folder: Folder): Promise<Folder> { export async function updateFolder(folder: Folder): Promise<Folder> {
@@ -86,32 +98,34 @@ export async function updateFolder(folder: Folder): Promise<Folder> {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(folder), body: JSON.stringify(folder),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to update folder") throw new Error("Failed to update folder");
} }
return response.json() return response.json();
} }
export async function deleteFolder(folderId: string): Promise<void> { export async function deleteFolder(folderId: string): Promise<void> {
const response = await fetch(`${API_BASE}/folders?id=${folderId}`, { const response = await fetch(`${API_BASE}/folders?id=${folderId}`, {
method: "DELETE", method: "DELETE",
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to delete folder") throw new Error("Failed to delete folder");
} }
} }
export async function addCategory(category: Omit<Category, "id">): Promise<Category> { export async function addCategory(
category: Omit<Category, "id">,
): Promise<Category> {
const response = await fetch(`${API_BASE}/categories`, { const response = await fetch(`${API_BASE}/categories`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(category), body: JSON.stringify(category),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to add category") throw new Error("Failed to add category");
} }
return response.json() return response.json();
} }
export async function updateCategory(category: Category): Promise<Category> { export async function updateCategory(category: Category): Promise<Category> {
@@ -119,55 +133,57 @@ export async function updateCategory(category: Category): Promise<Category> {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(category), body: JSON.stringify(category),
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to update category") throw new Error("Failed to update category");
} }
return response.json() return response.json();
} }
export async function deleteCategory(categoryId: string): Promise<void> { export async function deleteCategory(categoryId: string): Promise<void> {
const response = await fetch(`${API_BASE}/categories?id=${categoryId}`, { const response = await fetch(`${API_BASE}/categories?id=${categoryId}`, {
method: "DELETE", method: "DELETE",
}) });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to delete category") throw new Error("Failed to delete category");
} }
} }
// Auto-categorize a transaction based on keywords // Auto-categorize a transaction based on keywords
export function autoCategorize(description: string, categories: Category[]): string | null { export function autoCategorize(
const lowerDesc = description.toLowerCase() description: string,
categories: Category[],
): string | null {
const lowerDesc = description.toLowerCase();
for (const category of categories) { for (const category of categories) {
for (const keyword of category.keywords) { for (const keyword of category.keywords) {
const lowerKeyword = keyword.toLowerCase() const lowerKeyword = keyword.toLowerCase();
// Pour les keywords courts (< 6 chars), matcher uniquement des mots entiers // Pour les keywords courts (< 6 chars), matcher uniquement des mots entiers
// Évite les faux positifs comme "chat" dans "achat" // Évite les faux positifs comme "chat" dans "achat"
if (lowerKeyword.length < 6) { if (lowerKeyword.length < 6) {
const wordBoundary = new RegExp(`\\b${escapeRegex(lowerKeyword)}\\b`) const wordBoundary = new RegExp(`\\b${escapeRegex(lowerKeyword)}\\b`);
if (wordBoundary.test(lowerDesc)) { if (wordBoundary.test(lowerDesc)) {
return category.id return category.id;
} }
} else { } else {
// Pour les keywords plus longs, includes() suffit // Pour les keywords plus longs, includes() suffit
if (lowerDesc.includes(lowerKeyword)) { if (lowerDesc.includes(lowerKeyword)) {
return category.id return category.id;
} }
} }
} }
} }
return null return null;
} }
// Échappe les caractères spéciaux pour les regex // Échappe les caractères spéciaux pour les regex
function escapeRegex(str: string): string { function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
export function generateId(): string { export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
} }

View File

@@ -1,20 +1,26 @@
"use client" "use client";
import type { BankingData, Account, Transaction, Folder, Category } from "./types" import type {
import { defaultCategories, defaultRootFolder } from "./defaults" BankingData,
Account,
Transaction,
Folder,
Category,
} from "./types";
import { defaultCategories, defaultRootFolder } from "./defaults";
const STORAGE_KEY = "banking-app-data" const STORAGE_KEY = "banking-app-data";
// Convertir les CategoryDefinition en Category pour le localStorage // Convertir les CategoryDefinition en Category pour le localStorage
function buildCategoriesFromDefaults(): Category[] { function buildCategoriesFromDefaults(): Category[] {
const slugToId = new Map<string, string>() const slugToId = new Map<string, string>();
const categories: Category[] = [] const categories: Category[] = [];
// D'abord les parents // D'abord les parents
const parents = defaultCategories.filter((c) => c.parentSlug === null) const parents = defaultCategories.filter((c) => c.parentSlug === null);
parents.forEach((cat, index) => { parents.forEach((cat, index) => {
const id = `cat-${index + 1}` const id = `cat-${index + 1}`;
slugToId.set(cat.slug, id) slugToId.set(cat.slug, id);
categories.push({ categories.push({
id, id,
name: cat.name, name: cat.name,
@@ -22,14 +28,14 @@ function buildCategoriesFromDefaults(): Category[] {
icon: cat.icon, icon: cat.icon,
keywords: cat.keywords, keywords: cat.keywords,
parentId: null, parentId: null,
}) });
}) });
// Puis les enfants // Puis les enfants
const children = defaultCategories.filter((c) => c.parentSlug !== null) const children = defaultCategories.filter((c) => c.parentSlug !== null);
children.forEach((cat, index) => { children.forEach((cat, index) => {
const id = `cat-${parents.length + index + 1}` const id = `cat-${parents.length + index + 1}`;
slugToId.set(cat.slug, id) slugToId.set(cat.slug, id);
categories.push({ categories.push({
id, id,
name: cat.name, name: cat.name,
@@ -37,10 +43,10 @@ function buildCategoriesFromDefaults(): Category[] {
icon: cat.icon, icon: cat.icon,
keywords: cat.keywords, keywords: cat.keywords,
parentId: cat.parentSlug ? slugToId.get(cat.parentSlug) || null : null, parentId: cat.parentSlug ? slugToId.get(cat.parentSlug) || null : null,
}) });
}) });
return categories return categories;
} }
const defaultData: BankingData = { const defaultData: BankingData = {
@@ -48,148 +54,163 @@ const defaultData: BankingData = {
transactions: [], transactions: [],
folders: [defaultRootFolder], folders: [defaultRootFolder],
categories: buildCategoriesFromDefaults(), categories: buildCategoriesFromDefaults(),
} };
export function loadData(): BankingData { export function loadData(): BankingData {
if (typeof window === "undefined") return defaultData if (typeof window === "undefined") return defaultData;
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) { if (!stored) {
saveData(defaultData) saveData(defaultData);
return defaultData return defaultData;
} }
try { try {
return JSON.parse(stored) return JSON.parse(stored);
} catch { } catch {
return defaultData return defaultData;
} }
} }
export function saveData(data: BankingData): void { export function saveData(data: BankingData): void {
if (typeof window === "undefined") return if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} }
export function addAccount(account: Account): BankingData { export function addAccount(account: Account): BankingData {
const data = loadData() const data = loadData();
data.accounts.push(account) data.accounts.push(account);
saveData(data) saveData(data);
return data return data;
} }
export function updateAccount(account: Account): BankingData { export function updateAccount(account: Account): BankingData {
const data = loadData() const data = loadData();
const index = data.accounts.findIndex((a) => a.id === account.id) const index = data.accounts.findIndex((a) => a.id === account.id);
if (index !== -1) { if (index !== -1) {
data.accounts[index] = account data.accounts[index] = account;
saveData(data) saveData(data);
} }
return data return data;
} }
export function deleteAccount(accountId: string): BankingData { export function deleteAccount(accountId: string): BankingData {
const data = loadData() const data = loadData();
data.accounts = data.accounts.filter((a) => a.id !== accountId) data.accounts = data.accounts.filter((a) => a.id !== accountId);
data.transactions = data.transactions.filter((t) => t.accountId !== accountId) data.transactions = data.transactions.filter(
saveData(data) (t) => t.accountId !== accountId,
return data );
saveData(data);
return data;
} }
export function addTransactions(transactions: Transaction[]): BankingData { export function addTransactions(transactions: Transaction[]): BankingData {
const data = loadData() const data = loadData();
// Filter out duplicates based on fitId // Filter out duplicates based on fitId
const existingFitIds = new Set(data.transactions.map((t) => `${t.accountId}-${t.fitId}`)) const existingFitIds = new Set(
const newTransactions = transactions.filter((t) => !existingFitIds.has(`${t.accountId}-${t.fitId}`)) data.transactions.map((t) => `${t.accountId}-${t.fitId}`),
);
const newTransactions = transactions.filter(
(t) => !existingFitIds.has(`${t.accountId}-${t.fitId}`),
);
data.transactions.push(...newTransactions) data.transactions.push(...newTransactions);
saveData(data) saveData(data);
return data return data;
} }
export function updateTransaction(transaction: Transaction): BankingData { export function updateTransaction(transaction: Transaction): BankingData {
const data = loadData() const data = loadData();
const index = data.transactions.findIndex((t) => t.id === transaction.id) const index = data.transactions.findIndex((t) => t.id === transaction.id);
if (index !== -1) { if (index !== -1) {
data.transactions[index] = transaction data.transactions[index] = transaction;
saveData(data) saveData(data);
} }
return data return data;
} }
export function addFolder(folder: Folder): BankingData { export function addFolder(folder: Folder): BankingData {
const data = loadData() const data = loadData();
data.folders.push(folder) data.folders.push(folder);
saveData(data) saveData(data);
return data return data;
} }
export function updateFolder(folder: Folder): BankingData { export function updateFolder(folder: Folder): BankingData {
const data = loadData() const data = loadData();
const index = data.folders.findIndex((f) => f.id === folder.id) const index = data.folders.findIndex((f) => f.id === folder.id);
if (index !== -1) { if (index !== -1) {
data.folders[index] = folder data.folders[index] = folder;
saveData(data) saveData(data);
} }
return data return data;
} }
export function deleteFolder(folderId: string): BankingData { export function deleteFolder(folderId: string): BankingData {
const data = loadData() const data = loadData();
// Move accounts to root // Move accounts to root
data.accounts = data.accounts.map((a) => (a.folderId === folderId ? { ...a, folderId: "folder-root" } : a)) data.accounts = data.accounts.map((a) =>
a.folderId === folderId ? { ...a, folderId: "folder-root" } : a,
);
// Move subfolders to parent // Move subfolders to parent
const folder = data.folders.find((f) => f.id === folderId) const folder = data.folders.find((f) => f.id === folderId);
if (folder) { if (folder) {
data.folders = data.folders.map((f) => (f.parentId === folderId ? { ...f, parentId: folder.parentId } : f)) data.folders = data.folders.map((f) =>
f.parentId === folderId ? { ...f, parentId: folder.parentId } : f,
);
} }
data.folders = data.folders.filter((f) => f.id !== folderId) data.folders = data.folders.filter((f) => f.id !== folderId);
saveData(data) saveData(data);
return data return data;
} }
export function addCategory(category: Category): BankingData { export function addCategory(category: Category): BankingData {
const data = loadData() const data = loadData();
data.categories.push(category) data.categories.push(category);
saveData(data) saveData(data);
return data return data;
} }
export function updateCategory(category: Category): BankingData { export function updateCategory(category: Category): BankingData {
const data = loadData() const data = loadData();
const index = data.categories.findIndex((c) => c.id === category.id) const index = data.categories.findIndex((c) => c.id === category.id);
if (index !== -1) { if (index !== -1) {
data.categories[index] = category data.categories[index] = category;
saveData(data) saveData(data);
} }
return data return data;
} }
export function deleteCategory(categoryId: string): BankingData { export function deleteCategory(categoryId: string): BankingData {
const data = loadData() const data = loadData();
data.categories = data.categories.filter((c) => c.id !== categoryId) data.categories = data.categories.filter((c) => c.id !== categoryId);
// Remove category from transactions // Remove category from transactions
data.transactions = data.transactions.map((t) => (t.categoryId === categoryId ? { ...t, categoryId: null } : t)) data.transactions = data.transactions.map((t) =>
saveData(data) t.categoryId === categoryId ? { ...t, categoryId: null } : t,
return data );
saveData(data);
return data;
} }
// Auto-categorize a transaction based on keywords // Auto-categorize a transaction based on keywords
export function autoCategorize(description: string, categories: Category[]): string | null { export function autoCategorize(
const lowerDesc = description.toLowerCase() description: string,
categories: Category[],
): string | null {
const lowerDesc = description.toLowerCase();
for (const category of categories) { for (const category of categories) {
for (const keyword of category.keywords) { for (const keyword of category.keywords) {
if (lowerDesc.includes(keyword.toLowerCase())) { if (lowerDesc.includes(keyword.toLowerCase())) {
return category.id return category.id;
} }
} }
} }
return null return null;
} }
export function generateId(): string { export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
} }

View File

@@ -1,72 +1,72 @@
// Types for the banking management application // Types for the banking management application
export interface Transaction { export interface Transaction {
id: string id: string;
accountId: string accountId: string;
date: string date: string;
amount: number amount: number;
description: string description: string;
type: "DEBIT" | "CREDIT" type: "DEBIT" | "CREDIT";
categoryId: string | null categoryId: string | null;
isReconciled: boolean isReconciled: boolean;
fitId: string // OFX unique transaction ID fitId: string; // OFX unique transaction ID
memo?: string memo?: string;
checkNum?: string checkNum?: string;
} }
export interface Account { export interface Account {
id: string id: string;
name: string name: string;
bankId: string bankId: string;
accountNumber: string accountNumber: string;
type: "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" type: "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER";
folderId: string | null folderId: string | null;
balance: number balance: number;
currency: string currency: string;
lastImport: string | null lastImport: string | null;
} }
export interface Folder { export interface Folder {
id: string id: string;
name: string name: string;
parentId: string | null parentId: string | null;
color: string color: string;
icon: string icon: string;
} }
export interface Category { export interface Category {
id: string id: string;
name: string name: string;
color: string color: string;
icon: string icon: string;
keywords: string[] // For auto-categorization keywords: string[]; // For auto-categorization
parentId: string | null parentId: string | null;
} }
export interface BankingData { export interface BankingData {
accounts: Account[] accounts: Account[];
transactions: Transaction[] transactions: Transaction[];
folders: Folder[] folders: Folder[];
categories: Category[] categories: Category[];
} }
// OFX Parsed types // OFX Parsed types
export interface OFXTransaction { export interface OFXTransaction {
fitId: string fitId: string;
date: string date: string;
amount: number amount: number;
name: string name: string;
memo?: string memo?: string;
checkNum?: string checkNum?: string;
type: string type: string;
} }
export interface OFXAccount { export interface OFXAccount {
bankId: string bankId: string;
accountId: string accountId: string;
accountType: string accountType: string;
balance: number balance: number;
balanceDate: string balanceDate: string;
currency: string currency: string;
transactions: OFXTransaction[] transactions: OFXTransaction[];
} }

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from 'clsx' import { clsx, type ClassValue } from "clsx";
import { twMerge } from 'tailwind-merge' import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@@ -6,6 +6,6 @@ const nextConfig = {
images: { images: {
unoptimized: true, unoptimized: true,
}, },
} };
export default nextConfig export default nextConfig;

View File

@@ -84,6 +84,7 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"postcss": "^8.5", "postcss": "^8.5",
"prettier": "^3.7.0",
"tailwindcss": "^4.1.9", "tailwindcss": "^4.1.9",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw-animate-css": "1.3.3", "tw-animate-css": "1.3.3",

6511
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
/** @type {import('postcss-load-config').Config} */ /** @type {import('postcss-load-config').Config} */
const config = { const config = {
plugins: { plugins: {
'@tailwindcss/postcss': {}, "@tailwindcss/postcss": {},
}, },
} };
export default config export default config;

View File

@@ -1,10 +1,10 @@
import { PrismaClient } from "@prisma/client" import { PrismaClient } from "@prisma/client";
import { defaultCategories, defaultRootFolder } from "../lib/defaults" import { defaultCategories, defaultRootFolder } from "../lib/defaults";
const prisma = new PrismaClient() const prisma = new PrismaClient();
async function main() { async function main() {
console.log("🌱 Seeding database...") console.log("🌱 Seeding database...");
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Créer le dossier racine // Créer le dossier racine
@@ -13,22 +13,22 @@ async function main() {
where: { id: defaultRootFolder.id }, where: { id: defaultRootFolder.id },
update: {}, update: {},
create: defaultRootFolder, create: defaultRootFolder,
}) });
console.log("📁 Root folder:", rootFolder.name) console.log("📁 Root folder:", rootFolder.name);
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Créer les catégories (hiérarchiques) // Créer les catégories (hiérarchiques)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
const slugToId = new Map<string, string>() const slugToId = new Map<string, string>();
// D'abord les parents // D'abord les parents
const parents = defaultCategories.filter((c) => c.parentSlug === null) const parents = defaultCategories.filter((c) => c.parentSlug === null);
console.log(`\n🏷 Création de ${parents.length} catégories parentes...`) console.log(`\n🏷 Création de ${parents.length} catégories parentes...`);
for (const category of parents) { for (const category of parents) {
const existing = await prisma.category.findFirst({ const existing = await prisma.category.findFirst({
where: { name: category.name, parentId: null }, where: { name: category.name, parentId: null },
}) });
if (!existing) { if (!existing) {
const created = await prisma.category.create({ const created = await prisma.category.create({
@@ -39,29 +39,29 @@ async function main() {
keywords: JSON.stringify(category.keywords), keywords: JSON.stringify(category.keywords),
parentId: null, parentId: null,
}, },
}) });
slugToId.set(category.slug, created.id) slugToId.set(category.slug, created.id);
console.log(`${category.name}`) console.log(`${category.name}`);
} else { } else {
slugToId.set(category.slug, existing.id) slugToId.set(category.slug, existing.id);
} }
} }
// Puis les enfants // Puis les enfants
const children = defaultCategories.filter((c) => c.parentSlug !== null) const children = defaultCategories.filter((c) => c.parentSlug !== null);
console.log(`\n📂 Création de ${children.length} sous-catégories...`) console.log(`\n📂 Création de ${children.length} sous-catégories...`);
for (const category of children) { for (const category of children) {
const parentId = slugToId.get(category.parentSlug!) const parentId = slugToId.get(category.parentSlug!);
if (!parentId) { if (!parentId) {
console.log(` ⚠️ Parent non trouvé pour: ${category.name}`) console.log(` ⚠️ Parent non trouvé pour: ${category.name}`);
continue continue;
} }
const existing = await prisma.category.findFirst({ const existing = await prisma.category.findFirst({
where: { name: category.name, parentId }, where: { name: category.name, parentId },
}) });
if (!existing) { if (!existing) {
const created = await prisma.category.create({ const created = await prisma.category.create({
@@ -72,24 +72,24 @@ async function main() {
keywords: JSON.stringify(category.keywords), keywords: JSON.stringify(category.keywords),
parentId, parentId,
}, },
}) });
slugToId.set(category.slug, created.id) slugToId.set(category.slug, created.id);
console.log(`${category.name}`) console.log(`${category.name}`);
} else { } else {
slugToId.set(category.slug, existing.id) slugToId.set(category.slug, existing.id);
} }
} }
// Stats // Stats
const totalCategories = await prisma.category.count() const totalCategories = await prisma.category.count();
console.log(`\n✨ Seeding terminé! ${totalCategories} catégories en base.`) console.log(`\n✨ Seeding terminé! ${totalCategories} catégories en base.`);
} }
main() main()
.catch((e) => { .catch((e) => {
console.error(e) console.error(e);
process.exit(1) process.exit(1);
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) });

View File

@@ -1,13 +1,12 @@
// Script simple pour initialiser la base de données // Script simple pour initialiser la base de données
// Utilisez Prisma Studio ou les API routes pour créer les données initiales // Utilisez Prisma Studio ou les API routes pour créer les données initiales
console.log("Pour initialiser la base de données:") console.log("Pour initialiser la base de données:");
console.log("1. Lancez Prisma Studio: pnpm db:studio") console.log("1. Lancez Prisma Studio: pnpm db:studio");
console.log("2. Créez manuellement le dossier racine 'folder-root' avec:") console.log("2. Créez manuellement le dossier racine 'folder-root' avec:");
console.log(" - id: folder-root") console.log(" - id: folder-root");
console.log(" - name: Mes Comptes") console.log(" - name: Mes Comptes");
console.log(" - parentId: null") console.log(" - parentId: null");
console.log(" - color: #6366f1") console.log(" - color: #6366f1");
console.log(" - icon: folder") console.log(" - icon: folder");
console.log("3. Créez les catégories par défaut via l'interface web") console.log("3. Créez les catégories par défaut via l'interface web");

View File

@@ -1,44 +1,44 @@
import { PrismaClient } from "@prisma/client" import { PrismaClient } from "@prisma/client";
import { defaultCategories, type CategoryDefinition } from "../lib/defaults" import { defaultCategories, type CategoryDefinition } from "../lib/defaults";
const prisma = new PrismaClient() const prisma = new PrismaClient();
async function main() { async function main() {
console.log("🏷️ Synchronisation des catégories hiérarchiques...") console.log("🏷️ Synchronisation des catégories hiérarchiques...");
console.log(` ${defaultCategories.length} catégories à synchroniser\n`) console.log(` ${defaultCategories.length} catégories à synchroniser\n`);
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// PHASE 0: Nettoyage des doublons existants // PHASE 0: Nettoyage des doublons existants
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50)) console.log("═".repeat(50));
console.log("PHASE 0: Nettoyage des doublons") console.log("PHASE 0: Nettoyage des doublons");
console.log("═".repeat(50)) console.log("═".repeat(50));
const allExisting = await prisma.category.findMany() const allExisting = await prisma.category.findMany();
const byNormalizedName = new Map<string, typeof allExisting>() const byNormalizedName = new Map<string, typeof allExisting>();
for (const cat of allExisting) { for (const cat of allExisting) {
const normalized = normalizeName(cat.name) const normalized = normalizeName(cat.name);
if (!byNormalizedName.has(normalized)) { if (!byNormalizedName.has(normalized)) {
byNormalizedName.set(normalized, []) byNormalizedName.set(normalized, []);
} }
byNormalizedName.get(normalized)!.push(cat) byNormalizedName.get(normalized)!.push(cat);
} }
let merged = 0 let merged = 0;
for (const [_normalized, cats] of byNormalizedName) { for (const [_normalized, cats] of byNormalizedName) {
if (cats.length > 1) { if (cats.length > 1) {
// Garder celui avec emoji // Garder celui avec emoji
let keeper = cats[0] let keeper = cats[0];
for (const cat of cats) { for (const cat of cats) {
if (/[\u{1F300}-\u{1F9FF}]/u.test(cat.name)) { if (/[\u{1F300}-\u{1F9FF}]/u.test(cat.name)) {
keeper = cat keeper = cat;
break break;
} }
} }
const toDelete: typeof cats = [] const toDelete: typeof cats = [];
for (const cat of cats) { for (const cat of cats) {
if (cat.id !== keeper.id) toDelete.push(cat) if (cat.id !== keeper.id) toDelete.push(cat);
} }
for (const dup of toDelete) { for (const dup of toDelete) {
@@ -46,92 +46,109 @@ async function main() {
await prisma.transaction.updateMany({ await prisma.transaction.updateMany({
where: { categoryId: dup.id }, where: { categoryId: dup.id },
data: { categoryId: keeper.id }, data: { categoryId: keeper.id },
}) });
// Transférer enfants // Transférer enfants
await prisma.category.updateMany({ await prisma.category.updateMany({
where: { parentId: dup.id }, where: { parentId: dup.id },
data: { parentId: keeper.id }, data: { parentId: keeper.id },
}) });
// Supprimer doublon // Supprimer doublon
await prisma.category.delete({ where: { id: dup.id } }) await prisma.category.delete({ where: { id: dup.id } });
console.log(`🗑️ Fusionné: "${dup.name}" → "${keeper.name}"`) console.log(`🗑️ Fusionné: "${dup.name}" → "${keeper.name}"`);
merged++ merged++;
} }
} }
} }
console.log(merged > 0 ? ` ${merged} doublons fusionnés` : " Aucun doublon ✓") console.log(
merged > 0 ? ` ${merged} doublons fusionnés` : " Aucun doublon ✓",
);
// Séparer parents et enfants // Séparer parents et enfants
const parentCategories = defaultCategories.filter((c) => c.parentSlug === null) const parentCategories = defaultCategories.filter(
const childCategories = defaultCategories.filter((c) => c.parentSlug !== null) (c) => c.parentSlug === null,
);
const childCategories = defaultCategories.filter(
(c) => c.parentSlug !== null,
);
console.log(`\n📁 ${parentCategories.length} catégories parentes`) console.log(`\n📁 ${parentCategories.length} catégories parentes`);
console.log(` └─ ${childCategories.length} sous-catégories\n`) console.log(` └─ ${childCategories.length} sous-catégories\n`);
// Map slug -> id (pour résoudre les parentId) // Map slug -> id (pour résoudre les parentId)
const slugToId = new Map<string, string>() const slugToId = new Map<string, string>();
let created = 0 let created = 0;
let updated = 0 let updated = 0;
let unchanged = 0 let unchanged = 0;
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// PHASE 1: Créer/MAJ les catégories parentes // PHASE 1: Créer/MAJ les catégories parentes
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50)) console.log("═".repeat(50));
console.log("PHASE 1: Catégories parentes") console.log("PHASE 1: Catégories parentes");
console.log("═".repeat(50)) console.log("═".repeat(50));
for (const category of parentCategories) { for (const category of parentCategories) {
const result = await upsertCategory(category, null) const result = await upsertCategory(category, null);
slugToId.set(category.slug, result.id) slugToId.set(category.slug, result.id);
if (result.action === "created") created++ if (result.action === "created") created++;
else if (result.action === "updated") updated++ else if (result.action === "updated") updated++;
else unchanged++ else unchanged++;
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// PHASE 2: Créer/MAJ les sous-catégories // PHASE 2: Créer/MAJ les sous-catégories
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50)) console.log("\n" + "═".repeat(50));
console.log("PHASE 2: Sous-catégories") console.log("PHASE 2: Sous-catégories");
console.log("═".repeat(50)) console.log("═".repeat(50));
for (const category of childCategories) { for (const category of childCategories) {
const parentId = slugToId.get(category.parentSlug!) const parentId = slugToId.get(category.parentSlug!);
if (!parentId) { if (!parentId) {
console.log(`⚠️ Parent introuvable pour: ${category.name} (parentSlug: ${category.parentSlug})`) console.log(
continue `⚠️ Parent introuvable pour: ${category.name} (parentSlug: ${category.parentSlug})`,
);
continue;
} }
const result = await upsertCategory(category, parentId) const result = await upsertCategory(category, parentId);
slugToId.set(category.slug, result.id) slugToId.set(category.slug, result.id);
if (result.action === "created") created++ if (result.action === "created") created++;
else if (result.action === "updated") updated++ else if (result.action === "updated") updated++;
else unchanged++ else unchanged++;
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// RÉSUMÉ // RÉSUMÉ
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50)) console.log("\n" + "═".repeat(50));
console.log("📊 RÉSUMÉ CATÉGORIES:") console.log("📊 RÉSUMÉ CATÉGORIES:");
console.log("═".repeat(50)) console.log("═".repeat(50));
console.log(` ✅ Créées: ${created}`) console.log(` ✅ Créées: ${created}`);
console.log(` ✏️ Mises à jour: ${updated}`) console.log(` ✏️ Mises à jour: ${updated}`);
console.log(` ⏭️ Inchangées: ${unchanged}`) console.log(` ⏭️ Inchangées: ${unchanged}`);
// Stats finales // Stats finales
const totalCategories = await prisma.category.count() const totalCategories = await prisma.category.count();
const parentCount = await prisma.category.count({ where: { parentId: null } }) const parentCount = await prisma.category.count({
const childCount = await prisma.category.count({ where: { NOT: { parentId: null } } }) where: { parentId: null },
const totalKeywords = defaultCategories.reduce((sum, c) => sum + c.keywords.length, 0) });
const childCount = await prisma.category.count({
where: { NOT: { parentId: null } },
});
const totalKeywords = defaultCategories.reduce(
(sum, c) => sum + c.keywords.length,
0,
);
console.log("\n📈 Base de données:") console.log("\n📈 Base de données:");
console.log(` Total catégories: ${totalCategories} (${parentCount} parents, ${childCount} enfants)`) console.log(
console.log(` Total keywords: ${totalKeywords}`) ` Total catégories: ${totalCategories} (${parentCount} parents, ${childCount} enfants)`,
);
console.log(` Total keywords: ${totalKeywords}`);
} }
// Normaliser un nom (enlever emojis, espaces multiples, lowercase) // Normaliser un nom (enlever emojis, espaces multiples, lowercase)
@@ -141,42 +158,51 @@ function normalizeName(name: string): string {
.replace(/[^\w\sÀ-ÿ]/g, "") // Keep only alphanumeric and accents .replace(/[^\w\sÀ-ÿ]/g, "") // Keep only alphanumeric and accents
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.toLowerCase() .toLowerCase()
.trim() .trim();
} }
async function upsertCategory( async function upsertCategory(
category: CategoryDefinition, category: CategoryDefinition,
parentId: string | null parentId: string | null,
): Promise<{ id: string; action: "created" | "updated" | "unchanged" }> { ): Promise<{ id: string; action: "created" | "updated" | "unchanged" }> {
// Chercher par nom exact d'abord // Chercher par nom exact d'abord
let existing = await prisma.category.findFirst({ let existing = await prisma.category.findFirst({
where: { name: category.name }, where: { name: category.name },
}) });
// Si pas trouvé, chercher par nom normalisé (sans emoji) dans TOUTES les catégories // Si pas trouvé, chercher par nom normalisé (sans emoji) dans TOUTES les catégories
if (!existing) { if (!existing) {
const allCategories = await prisma.category.findMany() const allCategories = await prisma.category.findMany();
const normalizedTarget = normalizeName(category.name) const normalizedTarget = normalizeName(category.name);
for (const cat of allCategories) { for (const cat of allCategories) {
if (normalizeName(cat.name) === normalizedTarget) { if (normalizeName(cat.name) === normalizedTarget) {
existing = cat existing = cat;
console.log(` 🔗 Match normalisé: "${cat.name}" → "${category.name}"`) console.log(
break ` 🔗 Match normalisé: "${cat.name}" → "${category.name}"`,
);
break;
} }
} }
} }
if (existing) { if (existing) {
// Comparer pour voir si mise à jour nécessaire // Comparer pour voir si mise à jour nécessaire
const existingKeywords = JSON.parse(existing.keywords) as string[] const existingKeywords = JSON.parse(existing.keywords) as string[];
const keywordsChanged = const keywordsChanged =
JSON.stringify(existingKeywords.sort()) !== JSON.stringify([...category.keywords].sort()) JSON.stringify(existingKeywords.sort()) !==
const nameChanged = existing.name !== category.name JSON.stringify([...category.keywords].sort());
const colorChanged = existing.color !== category.color const nameChanged = existing.name !== category.name;
const iconChanged = existing.icon !== category.icon const colorChanged = existing.color !== category.color;
const parentChanged = existing.parentId !== parentId const iconChanged = existing.icon !== category.icon;
const parentChanged = existing.parentId !== parentId;
if (nameChanged || keywordsChanged || colorChanged || iconChanged || parentChanged) { if (
nameChanged ||
keywordsChanged ||
colorChanged ||
iconChanged ||
parentChanged
) {
await prisma.category.update({ await prisma.category.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
@@ -186,18 +212,22 @@ async function upsertCategory(
keywords: JSON.stringify(category.keywords), keywords: JSON.stringify(category.keywords),
parentId: parentId, parentId: parentId,
}, },
}) });
console.log(`✏️ MAJ: ${existing.name}${nameChanged ? `${category.name}` : ""}`) console.log(
`✏️ MAJ: ${existing.name}${nameChanged ? `${category.name}` : ""}`,
);
if (keywordsChanged) { if (keywordsChanged) {
console.log(` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`) console.log(
` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`,
);
} }
if (parentChanged) { if (parentChanged) {
console.log(` └─ Parent modifié`) console.log(` └─ Parent modifié`);
} }
return { id: existing.id, action: "updated" } return { id: existing.id, action: "updated" };
} }
return { id: existing.id, action: "unchanged" } return { id: existing.id, action: "unchanged" };
} }
// Créer nouvelle catégorie // Créer nouvelle catégorie
@@ -209,17 +239,19 @@ async function upsertCategory(
keywords: JSON.stringify(category.keywords), keywords: JSON.stringify(category.keywords),
parentId: parentId, parentId: parentId,
}, },
}) });
console.log(`✅ Créée: ${category.name}${category.keywords.length > 0 ? ` (${category.keywords.length} keywords)` : ""}`) console.log(
`✅ Créée: ${category.name}${category.keywords.length > 0 ? ` (${category.keywords.length} keywords)` : ""}`,
);
return { id: created.id, action: "created" } return { id: created.id, action: "created" };
} }
main() main()
.catch((e) => { .catch((e) => {
console.error("❌ Erreur:", e) console.error("❌ Erreur:", e);
process.exit(1) process.exit(1);
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) });

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