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

@@ -26,23 +26,26 @@ Application web moderne de gestion personnelle de comptes bancaires avec import
## 📋 Prérequis
- Node.js 18+
- Node.js 18+
- pnpm (recommandé) ou npm/yarn
## 🔧 Installation
1. Clonez le dépôt :
```bash
git clone <url-du-repo>
cd bank-account-management-app
```
2. Installez les dépendances :
```bash
pnpm install
```
3. Lancez le serveur de développement :
```bash
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
L'application inclut des catégories pré-configurées avec des mots-clés pour la catégorisation automatique :
- Alimentation
- Transport
- Logement
@@ -132,6 +136,7 @@ Le thème sombre/clair peut être changé dans les paramètres. L'application d
### Structure des données
Les données sont structurées comme suit :
- **Accounts** : Comptes bancaires avec solde et métadonnées
- **Transactions** : Transactions avec montant, date, description, catégorie
- **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

View File

@@ -1,43 +1,68 @@
"use client"
"use client";
import { useState } from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
import { updateAccount, deleteAccount } from "@/lib/store-db"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, 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 { MoreVertical, Pencil, Trash2, Building2, CreditCard, Wallet, PiggyBank, RefreshCw } from "lucide-react"
import type { Account } from "@/lib/types"
import { cn } from "@/lib/utils"
import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
import { updateAccount, deleteAccount } from "@/lib/store-db";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
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 {
MoreVertical,
Pencil,
Trash2,
Building2,
CreditCard,
Wallet,
PiggyBank,
RefreshCw,
} from "lucide-react";
import type { Account } from "@/lib/types";
import { cn } from "@/lib/utils";
const accountTypeIcons = {
CHECKING: Wallet,
SAVINGS: PiggyBank,
CREDIT_CARD: CreditCard,
OTHER: Building2,
}
};
const accountTypeLabels = {
CHECKING: "Compte courant",
SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
}
};
export default function AccountsPage() {
const { data, isLoading, refresh, update } = useBankingData()
const [editingAccount, setEditingAccount] = useState<Account | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const { data, isLoading, refresh, update } = useBankingData();
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [formData, setFormData] = useState({
name: "",
type: "CHECKING" as Account["type"],
folderId: "folder-root",
})
});
if (isLoading || !data) {
return (
@@ -47,28 +72,28 @@ export default function AccountsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount)
}
}).format(amount);
};
const handleEdit = (account: Account) => {
setEditingAccount(account)
setEditingAccount(account);
setFormData({
name: account.name,
type: account.type,
folderId: account.folderId || "folder-root",
})
setIsDialogOpen(true)
}
});
setIsDialogOpen(true);
};
const handleSave = async () => {
if (!editingAccount) return
if (!editingAccount) return;
try {
const updatedAccount = {
@@ -76,34 +101,34 @@ export default function AccountsPage() {
name: formData.name,
type: formData.type,
folderId: formData.folderId,
}
await updateAccount(updatedAccount)
refresh()
setIsDialogOpen(false)
setEditingAccount(null)
};
await updateAccount(updatedAccount);
refresh();
setIsDialogOpen(false);
setEditingAccount(null);
} catch (error) {
console.error("Error updating account:", error)
alert("Erreur lors de la mise à jour du compte")
console.error("Error updating account:", error);
alert("Erreur lors de la mise à jour du compte");
}
}
};
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 {
await deleteAccount(accountId)
refresh()
await deleteAccount(accountId);
refresh();
} catch (error) {
console.error("Error deleting account:", error)
alert("Erreur lors de la suppression du compte")
console.error("Error deleting account:", error);
alert("Erreur lors de la suppression du compte");
}
}
};
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 (
<div className="flex h-screen bg-background">
@@ -113,11 +138,18 @@ export default function AccountsPage() {
<div className="flex items-center justify-between">
<div>
<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 className="text-right">
<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)}
</p>
</div>
@@ -129,15 +161,18 @@ export default function AccountsPage() {
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Aucun compte</h3>
<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>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.accounts.map((account) => {
const Icon = accountTypeIcons[account.type]
const folder = data.folders.find((f) => f.id === account.folderId)
const Icon = accountTypeIcons[account.type];
const folder = data.folders.find(
(f) => f.id === account.folderId,
);
return (
<Card key={account.id} className="relative">
@@ -148,22 +183,35 @@ export default function AccountsPage() {
<Icon className="w-5 h-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{account.name}</CardTitle>
<p className="text-xs text-muted-foreground">{accountTypeLabels[account.type]}</p>
<CardTitle className="text-base">
{account.name}
</CardTitle>
<p className="text-xs text-muted-foreground">
{accountTypeLabels[account.type]}
</p>
</div>
</div>
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(account)}>
<DropdownMenuItem
onClick={() => handleEdit(account)}
>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</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" />
Supprimer
</DropdownMenuItem>
@@ -175,23 +223,30 @@ export default function AccountsPage() {
<div
className={cn(
"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)}
</div>
<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>}
</div>
{account.lastImport && (
<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>
)}
</CardContent>
</Card>
)
);
})}
</div>
)}
@@ -206,13 +261,20 @@ export default function AccountsPage() {
<div className="space-y-4">
<div className="space-y-2">
<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 className="space-y-2">
<Label>Type de compte</Label>
<Select
value={formData.type}
onValueChange={(v) => setFormData({ ...formData, type: v as Account["type"] })}
onValueChange={(v) =>
setFormData({ ...formData, type: v as Account["type"] })
}
>
<SelectTrigger>
<SelectValue />
@@ -228,7 +290,10 @@ export default function AccountsPage() {
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select value={formData.folderId} onValueChange={(v) => setFormData({ ...formData, folderId: v })}>
<Select
value={formData.folderId}
onValueChange={(v) => setFormData({ ...formData, folderId: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -251,5 +316,5 @@ export default function AccountsPage() {
</DialogContent>
</Dialog>
</div>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,42 +1,57 @@
import { NextResponse } from "next/server"
import { transactionService } from "@/services/transaction.service"
import type { Transaction } from "@/lib/types"
import { NextResponse } from "next/server";
import { transactionService } from "@/services/transaction.service";
import type { Transaction } from "@/lib/types";
export async function POST(request: Request) {
try {
const transactions: Transaction[] = await request.json()
const result = await transactionService.createMany(transactions)
return NextResponse.json(result)
const transactions: Transaction[] = await request.json();
const result = await transactionService.createMany(transactions);
return NextResponse.json(result);
} catch (error) {
console.error("Error creating transactions:", error)
return NextResponse.json({ error: "Failed to create transactions" }, { status: 500 })
console.error("Error creating transactions:", error);
return NextResponse.json(
{ error: "Failed to create transactions" },
{ status: 500 },
);
}
}
export async function PUT(request: Request) {
try {
const transaction: Transaction = await request.json()
const updated = await transactionService.update(transaction.id, transaction)
return NextResponse.json(updated)
const transaction: Transaction = await request.json();
const updated = await transactionService.update(
transaction.id,
transaction,
);
return NextResponse.json(updated);
} catch (error) {
console.error("Error updating transaction:", error)
return NextResponse.json({ error: "Failed to update transaction" }, { status: 500 })
console.error("Error updating transaction:", error);
return NextResponse.json(
{ error: "Failed to update transaction" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
const { searchParams } = new URL(request.url);
const id = searchParams.get("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)
return NextResponse.json({ success: true })
await transactionService.delete(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting transaction:", error)
return NextResponse.json({ error: "Failed to delete transaction" }, { status: 500 })
console.error("Error deleting transaction:", error);
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 { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, 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"
import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
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 = [
"#22c55e", "#3b82f6", "#f59e0b", "#ec4899", "#ef4444",
"#8b5cf6", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
"#14b8a6", "#f43f5e", "#64748b", "#0891b2", "#dc2626",
]
"#22c55e",
"#3b82f6",
"#f59e0b",
"#ec4899",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#84cc16",
"#f97316",
"#6366f1",
"#14b8a6",
"#f43f5e",
"#64748b",
"#0891b2",
"#dc2626",
];
export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set())
const { data, isLoading, refresh } = useBankingData();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set(),
);
const [formData, setFormData] = useState({
name: "",
color: "#22c55e",
keywords: [] as string[],
parentId: null as string | null,
})
const [newKeyword, setNewKeyword] = useState("")
const [searchQuery, setSearchQuery] = useState("")
});
const [newKeyword, setNewKeyword] = useState("");
const [searchQuery, setSearchQuery] = useState("");
// Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } = useMemo(() => {
if (!data?.categories) return { parentCategories: [], childrenByParent: {}, orphanCategories: [] }
const { parentCategories, childrenByParent, orphanCategories } =
useMemo(() => {
if (!data?.categories)
return {
parentCategories: [],
childrenByParent: {},
orphanCategories: [],
};
const parents = data.categories.filter((c) => c.parentId === null)
const children: Record<string, Category[]> = {}
const orphans: Category[] = []
const parents = data.categories.filter((c) => c.parentId === null);
const children: Record<string, Category[]> = {};
const orphans: Category[] = [];
// Grouper les enfants par parent
data.categories
.filter((c) => c.parentId !== null)
.forEach((child) => {
const parentExists = parents.some((p) => p.id === child.parentId)
if (parentExists) {
if (!children[child.parentId!]) {
children[child.parentId!] = []
// Grouper les enfants par parent
data.categories
.filter((c) => c.parentId !== null)
.forEach((child) => {
const parentExists = parents.some((p) => p.id === child.parentId);
if (parentExists) {
if (!children[child.parentId!]) {
children[child.parentId!] = [];
}
children[child.parentId!].push(child);
} else {
orphans.push(child);
}
children[child.parentId!].push(child)
} else {
orphans.push(child)
}
})
});
return {
parentCategories: parents,
childrenByParent: children,
orphanCategories: orphans,
}
}, [data?.categories])
return {
parentCategories: parents,
childrenByParent: children,
orphanCategories: orphans,
};
}, [data?.categories]);
// Initialiser tous les parents comme ouverts
useState(() => {
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) {
return (
@@ -82,62 +138,75 @@ export default function CategoriesPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
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) => {
let categoryIds = [categoryId]
let categoryIds = [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 total = categoryTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0)
const count = categoryTransactions.length
return { total, count }
}
const categoryTransactions = data.transactions.filter((t) =>
categoryIds.includes(t.categoryId || ""),
);
const total = categoryTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount),
0,
);
const count = categoryTransactions.length;
return { total, count };
};
const toggleExpanded = (parentId: string) => {
const newExpanded = new Set(expandedParents)
const newExpanded = new Set(expandedParents);
if (newExpanded.has(parentId)) {
newExpanded.delete(parentId)
newExpanded.delete(parentId);
} else {
newExpanded.add(parentId)
newExpanded.add(parentId);
}
setExpandedParents(newExpanded)
}
setExpandedParents(newExpanded);
};
const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p) => p.id)))
}
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
};
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) => {
setEditingCategory(null)
setFormData({ name: "", color: "#22c55e", keywords: [], parentId })
setIsDialogOpen(true)
}
setEditingCategory(null);
setFormData({ name: "", color: "#22c55e", keywords: [], parentId });
setIsDialogOpen(true);
};
const handleEdit = (category: Category) => {
setEditingCategory(category)
setEditingCategory(category);
setFormData({
name: category.name,
color: category.color,
keywords: [...category.keywords],
parentId: category.parentId,
})
setIsDialogOpen(true)
}
});
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
@@ -148,7 +217,7 @@ export default function CategoriesPage() {
color: formData.color,
keywords: formData.keywords,
parentId: formData.parentId,
})
});
} else {
await addCategory({
name: formData.name,
@@ -156,78 +225,88 @@ export default function CategoriesPage() {
keywords: formData.keywords,
icon: "tag",
parentId: formData.parentId,
})
});
}
refresh()
setIsDialogOpen(false)
refresh();
setIsDialogOpen(false);
} catch (error) {
console.error("Error saving category:", error)
alert("Erreur lors de la sauvegarde de la catégorie")
console.error("Error saving category:", error);
alert("Erreur lors de la sauvegarde de la catégorie");
}
}
};
const handleDelete = async (categoryId: string) => {
const hasChildren = childrenByParent[categoryId]?.length > 0
const hasChildren = childrenByParent[categoryId]?.length > 0;
const message = hasChildren
? "Cette catégorie a des sous-catégories. Supprimer quand même ?"
: "Supprimer cette catégorie ?"
if (!confirm(message)) return
: "Supprimer cette catégorie ?";
if (!confirm(message)) return;
try {
await deleteCategory(categoryId)
refresh()
await deleteCategory(categoryId);
refresh();
} catch (error) {
console.error("Error deleting category:", error)
alert("Erreur lors de la suppression de la catégorie")
console.error("Error deleting category:", error);
alert("Erreur lors de la suppression de la catégorie");
}
}
};
const addKeyword = () => {
if (newKeyword.trim() && !formData.keywords.includes(newKeyword.trim().toLowerCase())) {
if (
newKeyword.trim() &&
!formData.keywords.includes(newKeyword.trim().toLowerCase())
) {
setFormData({
...formData,
keywords: [...formData.keywords, newKeyword.trim().toLowerCase()],
})
setNewKeyword("")
});
setNewKeyword("");
}
}
};
const removeKeyword = (keyword: string) => {
setFormData({
...formData,
keywords: formData.keywords.filter((k) => k !== keyword),
})
}
});
};
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 {
const { updateTransaction } = await import("@/lib/store-db")
const uncategorized = data.transactions.filter((t) => !t.categoryId)
const { updateTransaction } = await import("@/lib/store-db");
const uncategorized = data.transactions.filter((t) => !t.categoryId);
for (const transaction of uncategorized) {
const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""),
data.categories
)
data.categories,
);
if (categoryId) {
await updateTransaction({ ...transaction, categoryId })
await updateTransaction({ ...transaction, categoryId });
}
}
refresh()
refresh();
} catch (error) {
console.error("Error re-categorizing:", error)
alert("Erreur lors de la recatégorisation")
console.error("Error re-categorizing:", error);
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
const ChildCategoryCard = ({ category }: { category: Category }) => {
const stats = getCategoryStats(category.id)
const stats = getCategoryStats(category.id);
return (
<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"
style={{ backgroundColor: `${category.color}20` }}
>
<CategoryIcon icon={category.icon} color={category.color} size={12} />
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
</div>
<span className="text-sm truncate">{category.name}</span>
<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>
{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}
</Badge>
)}
</div>
<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" />
</Button>
<Button
@@ -263,8 +355,8 @@ export default function CategoriesPage() {
</Button>
</div>
</div>
)
}
);
};
return (
<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>
<p className="text-muted-foreground">
{parentCategories.length} catégories principales {" "}
{data.categories.length - parentCategories.length} sous-catégories
{data.categories.length - parentCategories.length}{" "}
sous-catégories
</p>
</div>
<div className="flex gap-2">
@@ -326,110 +419,141 @@ export default function CategoriesPage() {
<div className="space-y-1">
{parentCategories
.filter((parent) => {
if (!searchQuery.trim()) return true
const query = searchQuery.toLowerCase()
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
// Afficher si le parent matche
if (parent.name.toLowerCase().includes(query)) return true
if (parent.keywords.some((k) => k.toLowerCase().includes(query))) return true
if (parent.name.toLowerCase().includes(query)) return true;
if (
parent.keywords.some((k) => k.toLowerCase().includes(query))
)
return true;
// Ou si un enfant matche
const children = childrenByParent[parent.id] || []
const children = childrenByParent[parent.id] || [];
return children.some(
(c) =>
c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query))
)
c.keywords.some((k) => k.toLowerCase().includes(query)),
);
})
.map((parent) => {
const allChildren = childrenByParent[parent.id] || []
// Filtrer les enfants aussi si recherche active
const children = searchQuery.trim()
? allChildren.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) => k.toLowerCase().includes(searchQuery.toLowerCase())) ||
// Garder tous les enfants si le parent matche
parent.name.toLowerCase().includes(searchQuery.toLowerCase())
)
: allChildren
const stats = getCategoryStats(parent.id, true)
const isExpanded = expandedParents.has(parent.id) || (searchQuery.trim() !== "" && children.length > 0)
const allChildren = childrenByParent[parent.id] || [];
// Filtrer les enfants aussi si recherche active
const children = searchQuery.trim()
? allChildren.filter(
(c) =>
c.name
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase()),
) ||
// Garder tous les enfants si le parent matche
parent.name
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
: allChildren;
const stats = getCategoryStats(parent.id, true);
const isExpanded =
expandedParents.has(parent.id) ||
(searchQuery.trim() !== "" && children.length > 0);
return (
<div key={parent.id} className="border rounded-lg bg-card">
<Collapsible open={isExpanded} onOpenChange={() => toggleExpanded(parent.id)}>
<div className="flex items-center justify-between px-3 py-2">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<CategoryIcon icon={parent.icon} color={parent.color} size={14} />
</div>
<span className="font-medium text-sm truncate">{parent.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)}
</span>
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-1 shrink-0 ml-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation()
handleNewCategory(parent.id)
}}
>
<Plus className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(parent)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(parent.id)}
className="text-red-600"
return (
<div key={parent.id} className="border rounded-lg bg-card">
<Collapsible
open={isExpanded}
onOpenChange={() => toggleExpanded(parent.id)}
>
<div className="flex items-center justify-between px-3 py-2">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CategoryIcon
icon={parent.icon}
color={parent.color}
size={14}
/>
</div>
<span className="font-medium text-sm truncate">
{parent.name}
</span>
<span className="text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération
{stats.count > 1 ? "s" : ""} {" "}
{formatCurrency(stats.total)}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{children.map((child) => (
<ChildCategoryCard key={child.id} category={child} />
))}
<div className="flex items-center gap-1 shrink-0 ml-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleNewCategory(parent.id);
}}
>
<Plus className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleEdit(parent)}
>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(parent.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
Aucune sous-catégorie
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
)
})}
</div>
<CollapsibleContent>
{children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{children.map((child) => (
<ChildCategoryCard
key={child.id}
category={child}
/>
))}
</div>
) : (
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
Aucune sous-catégorie
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
})}
{/* Catégories orphelines (sans parent valide) */}
{orphanCategories.length > 0 && (
@@ -465,14 +589,19 @@ export default function CategoriesPage() {
<Select
value={formData.parentId || "none"}
onValueChange={(value) =>
setFormData({ ...formData, parentId: value === "none" ? null : value })
setFormData({
...formData,
parentId: value === "none" ? null : value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Aucune (catégorie principale)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune (catégorie principale)</SelectItem>
<SelectItem value="none">
Aucune (catégorie principale)
</SelectItem>
{parentCategories
.filter((p) => p.id !== editingCategory?.id)
.map((parent) => (
@@ -495,7 +624,9 @@ export default function CategoriesPage() {
<Label>Nom</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Ex: Alimentation"
/>
</div>
@@ -510,7 +641,8 @@ export default function CategoriesPage() {
onClick={() => setFormData({ ...formData, color })}
className={cn(
"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 }}
/>
@@ -526,7 +658,9 @@ export default function CategoriesPage() {
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
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">
<Plus className="w-4 h-4" />
@@ -557,5 +691,5 @@ export default function CategoriesPage() {
</DialogContent>
</Dialog>
</div>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
"use client"
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import type { BankingData } from "@/lib/types"
import { cn } from "@/lib/utils"
import { Building2 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import type { BankingData } from "@/lib/types";
import { cn } from "@/lib/utils";
import { Building2 } from "lucide-react";
interface AccountsSummaryProps {
data: BankingData
data: BankingData;
}
export function AccountsSummary({ data }: AccountsSummaryProps) {
@@ -15,10 +15,12 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
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) {
return (
@@ -30,11 +32,13 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<div className="flex flex-col items-center justify-center py-8 text-center">
<Building2 className="w-12 h-12 text-muted-foreground mb-3" />
<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>
</CardContent>
</Card>
)
);
}
return (
@@ -45,7 +49,10 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<CardContent>
<div className="space-y-4">
{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 (
<div key={account.id} className="space-y-2">
@@ -57,25 +64,31 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<div>
<p className="font-medium text-sm">{account.name}</p>
<p className="text-xs text-muted-foreground">
{account.accountNumber.slice(-4).padStart(account.accountNumber.length, "*")}
{account.accountNumber
.slice(-4)
.padStart(account.accountNumber.length, "*")}
</p>
</div>
</div>
<span
className={cn(
"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)}
</span>
</div>
{account.balance > 0 && <Progress value={percentage} className="h-1.5" />}
{account.balance > 0 && (
<Progress value={percentage} className="h-1.5" />
)}
</div>
)
);
})}
</div>
</CardContent>
</Card>
)
);
}

View File

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

View File

@@ -1,46 +1,60 @@
"use client"
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react"
import type { BankingData } from "@/lib/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
import type { BankingData } from "@/lib/types";
interface OverviewCardsProps {
data: BankingData
data: BankingData;
}
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()
thisMonth.setDate(1)
const thisMonthStr = thisMonth.toISOString().slice(0, 7)
const thisMonth = new Date();
thisMonth.setDate(1);
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 total = data.transactions.length
const reconciledPercent = total > 0 ? Math.round((reconciled / total) * 100) : 0
const reconciled = data.transactions.filter((t) => t.isReconciled).length;
const total = data.transactions.length;
const reconciledPercent =
total > 0 ? Math.round((reconciled / total) * 100) : 0;
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount)
}
}).format(amount);
};
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<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" />
</CardHeader>
<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)}
</div>
<p className="text-xs text-muted-foreground mt-1">
@@ -51,35 +65,49 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card>
<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" />
</CardHeader>
<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">
{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>
</CardContent>
</Card>
<Card>
<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" />
</CardHeader>
<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">
{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>
</CardContent>
</Card>
<Card>
<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" />
</CardHeader>
<CardContent>
@@ -90,7 +118,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
</CardContent>
</Card>
</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 { Badge } from "@/components/ui/badge"
import { CheckCircle2, Circle } from "lucide-react"
import { CategoryIcon } from "@/components/ui/category-icon"
import type { BankingData } from "@/lib/types"
import { cn } from "@/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CheckCircle2, Circle } from "lucide-react";
import { CategoryIcon } from "@/components/ui/category-icon";
import type { BankingData } from "@/lib/types";
import { cn } from "@/lib/utils";
interface RecentTransactionsProps {
data: BankingData
data: BankingData;
}
export function RecentTransactions({ data }: RecentTransactionsProps) {
const recentTransactions = [...data.transactions]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 10)
.slice(0, 10);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount)
}
}).format(amount);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
})
}
});
};
const getCategory = (categoryId: string | null) => {
if (!categoryId) return null
return data.categories.find((c) => c.id === categoryId)
}
if (!categoryId) return null;
return data.categories.find((c) => c.id === categoryId);
};
const getAccount = (accountId: string) => {
return data.accounts.find((a) => a.id === accountId)
}
return data.accounts.find((a) => a.id === accountId);
};
if (recentTransactions.length === 0) {
return (
@@ -48,11 +48,13 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-center">
<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>
</CardContent>
</Card>
)
);
}
return (
@@ -63,8 +65,8 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
<CardContent>
<div className="space-y-3">
{recentTransactions.map((transaction) => {
const category = getCategory(transaction.categoryId)
const account = getAccount(transaction.accountId)
const category = getCategory(transaction.categoryId);
const account = getAccount(transaction.accountId);
return (
<div
@@ -80,17 +82,32 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
</div>
<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">
<span className="text-xs text-muted-foreground">{formatDate(transaction.date)}</span>
{account && <span className="text-xs text-muted-foreground"> {account.name}</span>}
<span className="text-xs text-muted-foreground">
{formatDate(transaction.date)}
</span>
{account && (
<span className="text-xs text-muted-foreground">
{account.name}
</span>
)}
{category && (
<Badge
variant="secondary"
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}
</Badge>
)}
@@ -100,17 +117,19 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
<div
className={cn(
"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 ? "+" : ""}
{formatCurrency(transaction.amount)}
</div>
</div>
)
);
})}
</div>
</CardContent>
</Card>
)
);
}

View File

@@ -1,10 +1,10 @@
"use client"
"use client";
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
LayoutDashboard,
Wallet,
@@ -15,7 +15,7 @@ import {
ChevronLeft,
ChevronRight,
Settings,
} from "lucide-react"
} from "lucide-react";
const navItems = [
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
@@ -24,11 +24,11 @@ const navItems = [
{ href: "/transactions", label: "Transactions", icon: Upload },
{ href: "/categories", label: "Catégories", icon: Tags },
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
]
];
export function Sidebar() {
const pathname = usePathname()
const [collapsed, setCollapsed] = useState(false)
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
@@ -46,36 +46,54 @@ export function Sidebar() {
<span className="font-semibold text-foreground">FinTrack</span>
</div>
)}
<Button variant="ghost" size="icon" onClick={() => setCollapsed(!collapsed)} className="ml-auto">
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(!collapsed)}
className="ml-auto"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</Button>
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href
const isActive = pathname === item.href;
return (
<Link key={item.href} href={item.href}>
<Button
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" />
{!collapsed && <span>{item.label}</span>}
</Button>
</Link>
)
);
})}
</nav>
<div className="p-2 border-t border-border">
<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" />
{!collapsed && <span>Paramètres</span>}
</Button>
</Link>
</div>
</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 { useDropzone } from "react-dropzone"
import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import {
Dialog,
DialogContent,
@@ -11,78 +11,109 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } 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"
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} 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 {
children: React.ReactNode
onImportComplete?: () => void
children: React.ReactNode;
onImportComplete?: () => void;
}
type ImportStep = "upload" | "configure" | "importing" | "success" | "error"
type ImportStep = "upload" | "configure" | "importing" | "success" | "error";
interface ImportResult {
fileName: string
accountName: string
transactionsImported: number
isNew: boolean
error?: string
fileName: string;
accountName: string;
transactionsImported: number;
isNew: boolean;
error?: string;
}
export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogProps) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState<ImportStep>("upload")
export function OFXImportDialog({
children,
onImportComplete,
}: OFXImportDialogProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<ImportStep>("upload");
// Single file mode
const [parsedData, setParsedData] = useState<OFXAccount | null>(null)
const [accountName, setAccountName] = useState("")
const [selectedFolder, setSelectedFolder] = useState<string>("folder-root")
const [folders, setFolders] = useState<Folder[]>([])
const [existingAccountId, setExistingAccountId] = useState<string | null>(null)
const [parsedData, setParsedData] = useState<OFXAccount | null>(null);
const [accountName, setAccountName] = useState("");
const [selectedFolder, setSelectedFolder] = useState<string>("folder-root");
const [folders, setFolders] = useState<Folder[]>([]);
const [existingAccountId, setExistingAccountId] = useState<string | null>(
null,
);
// Multi-file mode
const [importResults, setImportResults] = useState<ImportResult[]>([])
const [importProgress, setImportProgress] = useState(0)
const [totalFiles, setTotalFiles] = useState(0)
const [error, setError] = useState<string | null>(null)
const [importResults, setImportResults] = useState<ImportResult[]>([]);
const [importProgress, setImportProgress] = useState(0);
const [totalFiles, setTotalFiles] = useState(0);
const [error, setError] = useState<string | null>(null);
// Import a single OFX file directly (for multi-file mode)
const importOFXDirect = async (
parsed: OFXAccount,
fileName: string,
data: BankingData
data: BankingData,
): Promise<ImportResult> => {
try {
// Check if account already exists
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 accountName: string
let isNew = false
let accountId: string;
let accountName: string;
let isNew = false;
if (existing) {
accountId = existing.id
accountName = existing.name
accountId = existing.id;
accountName = existing.name;
await updateAccount({
...existing,
balance: parsed.balance,
lastImport: new Date().toISOString(),
})
});
} else {
isNew = true
accountName = `Compte ${parsed.accountId.slice(-4)}`
isNew = true;
accountName = `Compte ${parsed.accountId.slice(-4)}`;
const newAccount = await addAccount({
name: accountName,
bankId: parsed.bankId,
@@ -92,14 +123,16 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
balance: parsed.balance,
currency: parsed.currency,
lastImport: new Date().toISOString(),
})
accountId = newAccount.id
});
accountId = newAccount.id;
}
// Add transactions with auto-categorization
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
.filter((t) => !existingFitIds.has(t.fitId))
@@ -110,15 +143,18 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
amount: t.amount,
description: t.name,
type: t.amount >= 0 ? "CREDIT" : "DEBIT",
categoryId: autoCategorize(t.name + " " + (t.memo || ""), data.categories),
categoryId: autoCategorize(
t.name + " " + (t.memo || ""),
data.categories,
),
isReconciled: false,
fitId: t.fitId,
memo: t.memo,
checkNum: t.checkNum,
}))
}));
if (newTransactions.length > 0) {
await addTransactions(newTransactions)
await addTransactions(newTransactions);
}
return {
@@ -126,7 +162,7 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
accountName,
transactionsImported: newTransactions.length,
isNew,
}
};
} catch (err) {
return {
fileName,
@@ -134,85 +170,90 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
transactionsImported: 0,
isNew: false,
error: err instanceof Error ? err.message : "Erreur inconnue",
}
};
}
}
};
const onDrop = useCallback(async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
// Multi-file mode: import directly
if (acceptedFiles.length > 1) {
setStep("importing")
setTotalFiles(acceptedFiles.length)
setImportProgress(0)
setImportResults([])
// Multi-file mode: import directly
if (acceptedFiles.length > 1) {
setStep("importing");
setTotalFiles(acceptedFiles.length);
setImportProgress(0);
setImportResults([]);
const data = await loadData()
const results: ImportResult[] = []
const data = await loadData();
const results: ImportResult[] = [];
for (let i = 0; i < acceptedFiles.length; i++) {
const file = acceptedFiles[i]
const content = await file.text()
const parsed = parseOFX(content)
for (let i = 0; i < acceptedFiles.length; i++) {
const file = acceptedFiles[i];
const content = await file.text();
const parsed = parseOFX(content);
if (parsed) {
// Reload data after each import to get updated accounts/transactions
const freshData = i === 0 ? data : await loadData()
const result = await importOFXDirect(parsed, file.name, freshData)
results.push(result)
} else {
results.push({
fileName: file.name,
accountName: "Erreur",
transactionsImported: 0,
isNew: false,
error: "Format OFX invalide",
})
if (parsed) {
// Reload data after each import to get updated accounts/transactions
const freshData = i === 0 ? data : await loadData();
const result = await importOFXDirect(parsed, file.name, freshData);
results.push(result);
} else {
results.push({
fileName: file.name,
accountName: "Erreur",
transactionsImported: 0,
isNew: false,
error: "Format OFX invalide",
});
}
setImportProgress(((i + 1) / acceptedFiles.length) * 100);
setImportResults([...results]);
}
setImportProgress(((i + 1) / acceptedFiles.length) * 100)
setImportResults([...results])
setStep("success");
onImportComplete?.();
return;
}
setStep("success")
onImportComplete?.()
return
}
// Single file mode: show configuration
const file = acceptedFiles[0];
const content = await file.text();
const parsed = parseOFX(content);
// Single file mode: show configuration
const file = acceptedFiles[0]
const content = await file.text()
const parsed = parseOFX(content)
if (parsed) {
setParsedData(parsed);
setAccountName(`Compte ${parsed.accountId.slice(-4)}`);
if (parsed) {
setParsedData(parsed)
setAccountName(`Compte ${parsed.accountId.slice(-4)}`)
try {
const data = await loadData();
setFolders(data.folders);
try {
const data = await loadData()
setFolders(data.folders)
const existing = data.accounts.find(
(a) =>
a.accountNumber === parsed.accountId &&
a.bankId === parsed.bankId,
);
if (existing) {
setExistingAccountId(existing.id);
setAccountName(existing.name);
setSelectedFolder(existing.folderId || "folder-root");
}
const existing = data.accounts.find(
(a) => a.accountNumber === parsed.accountId && a.bankId === parsed.bankId
)
if (existing) {
setExistingAccountId(existing.id)
setAccountName(existing.name)
setSelectedFolder(existing.folderId || "folder-root")
setStep("configure");
} catch (err) {
console.error("Error loading data:", err);
setError("Erreur lors du chargement des données");
setStep("error");
}
setStep("configure")
} catch (err) {
console.error("Error loading data:", err)
setError("Erreur lors du chargement des données")
setStep("error")
} else {
setError("Impossible de lire le fichier OFX. Vérifiez le format.");
setStep("error");
}
} else {
setError("Impossible de lire le fichier OFX. Vérifiez le format.")
setStep("error")
}
}, [onImportComplete])
},
[onImportComplete],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
@@ -222,20 +263,22 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
"text/plain": [".ofx", ".qfx"],
},
// No maxFiles limit - accept multiple files
})
});
const handleImport = async () => {
if (!parsedData) return
if (!parsedData) return;
try {
setStep("importing")
const data = await loadData()
setStep("importing");
const data = await loadData();
let accountId: string
let accountId: string;
if (existingAccountId) {
accountId = existingAccountId
const existingAccount = data.accounts.find((a) => a.id === existingAccountId)
accountId = existingAccountId;
const existingAccount = data.accounts.find(
(a) => a.id === existingAccountId,
);
if (existingAccount) {
await updateAccount({
...existingAccount,
@@ -243,7 +286,7 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
folderId: selectedFolder,
balance: parsedData.balance,
lastImport: new Date().toISOString(),
})
});
}
} else {
const newAccount = await addAccount({
@@ -255,13 +298,15 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
balance: parsedData.balance,
currency: parsedData.currency,
lastImport: new Date().toISOString(),
})
accountId = newAccount.id
});
accountId = newAccount.id;
}
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
.filter((t) => !existingFitIds.has(t.fitId))
@@ -272,57 +317,65 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
amount: t.amount,
description: t.name,
type: t.amount >= 0 ? "CREDIT" : "DEBIT",
categoryId: autoCategorize(t.name + " " + (t.memo || ""), data.categories),
categoryId: autoCategorize(
t.name + " " + (t.memo || ""),
data.categories,
),
isReconciled: false,
fitId: t.fitId,
memo: t.memo,
checkNum: t.checkNum,
}))
}));
if (newTransactions.length > 0) {
await addTransactions(newTransactions)
await addTransactions(newTransactions);
}
setImportResults([{
fileName: "Import",
accountName,
transactionsImported: newTransactions.length,
isNew: !existingAccountId,
}])
setStep("success")
onImportComplete?.()
setImportResults([
{
fileName: "Import",
accountName,
transactionsImported: newTransactions.length,
isNew: !existingAccountId,
},
]);
setStep("success");
onImportComplete?.();
} catch (err) {
console.error("Error importing:", err)
setError("Erreur lors de l'import")
setStep("error")
console.error("Error importing:", err);
setError("Erreur lors de l'import");
setStep("error");
}
}
};
const handleClose = () => {
setOpen(false)
setOpen(false);
setTimeout(() => {
setStep("upload")
setParsedData(null)
setAccountName("")
setSelectedFolder("folder-root")
setExistingAccountId(null)
setError(null)
setImportResults([])
setImportProgress(0)
setTotalFiles(0)
}, 200)
}
setStep("upload");
setParsedData(null);
setAccountName("");
setSelectedFolder("folder-root");
setExistingAccountId(null);
setError(null);
setImportResults([]);
setImportProgress(0);
setTotalFiles(0);
}, 200);
};
const totalTransactions = importResults.reduce((sum, r) => sum + r.transactionsImported, 0)
const successCount = importResults.filter((r) => !r.error).length
const errorCount = importResults.filter((r) => r.error).length
const totalTransactions = importResults.reduce(
(sum, r) => sum + r.transactionsImported,
0,
);
const successCount = importResults.filter((r) => !r.error).length;
const errorCount = importResults.filter((r) => r.error).length;
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) handleClose()
else setOpen(true)
if (!o) handleClose();
else setOpen(true);
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
@@ -336,14 +389,16 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
{step === "error" && "Erreur d'import"}
</DialogTitle>
<DialogDescription>
{step === "upload" && "Glissez-déposez vos fichiers OFX ou cliquez pour sélectionner"}
{step === "configure" && "Vérifiez les informations du compte avant l'import"}
{step === "importing" && `Import de ${totalFiles} fichier${totalFiles > 1 ? "s" : ""}...`}
{step === "success" && (
importResults.length > 1
{step === "upload" &&
"Glissez-déposez vos fichiers OFX ou cliquez pour sélectionner"}
{step === "configure" &&
"Vérifiez les informations du compte avant l'import"}
{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`
: `${totalTransactions} nouvelles transactions importées`
)}
: `${totalTransactions} nouvelles transactions importées`)}
{step === "error" && error}
</DialogDescription>
</DialogHeader>
@@ -353,16 +408,21 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
{...getRootProps()}
className={cn(
"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()} />
<Upload className="w-10 h-10 mx-auto mb-4 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 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>
</div>
)}
@@ -372,12 +432,15 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<FileText className="w-8 h-8 text-primary" />
<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">
Solde:{" "}
{new Intl.NumberFormat("fr-FR", { style: "currency", currency: parsedData.currency }).format(
parsedData.balance,
)}
{new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: parsedData.currency,
}).format(parsedData.balance)}
</p>
</div>
</div>
@@ -410,7 +473,8 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
{existingAccountId && (
<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>
)}
@@ -457,7 +521,7 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
{step === "success" && (
<div className="py-4">
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-emerald-600" />
{importResults.length > 1 && (
<div className="max-h-48 overflow-auto space-y-1 text-sm mb-4 border rounded-lg p-2">
{importResults.map((result, i) => (
@@ -468,14 +532,21 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{result.accountName}</p>
<p className="text-xs text-muted-foreground truncate">{result.fileName}</p>
<p className="truncate font-medium">
{result.accountName}
</p>
<p className="text-xs text-muted-foreground truncate">
{result.fileName}
</p>
</div>
{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">
{result.isNew ? "Nouveau" : "Mis à jour"} +{result.transactionsImported}
{result.isNew ? "Nouveau" : "Mis à jour"} +
{result.transactionsImported}
</span>
)}
</div>
@@ -503,5 +574,5 @@ export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogP
)}
</DialogContent>
</Dialog>
)
);
}

View File

@@ -1,11 +1,11 @@
'use client'
"use client";
import * as React from 'react'
import * as React from "react";
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
} from "next-themes";
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 AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDownIcon } from 'lucide-react'
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
@@ -19,10 +19,10 @@ function AccordionItem({
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
);
}
function AccordionTrigger({
@@ -35,7 +35,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
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,
)}
{...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" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
);
}
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"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</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 AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
);
}
function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
);
}
function AlertDialogOverlay({
@@ -36,12 +36,12 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
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,
)}
{...props}
/>
)
);
}
function AlertDialogContent({
@@ -54,42 +54,42 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
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,
)}
{...props}
/>
</AlertDialogPortal>
)
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<"div">) {
return (
<div
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}
/>
)
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
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,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
@@ -99,10 +99,10 @@ function AlertDialogTitle({
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)}
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -112,10 +112,10 @@ function AlertDialogDescription({
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)}
{...props}
/>
)
);
}
function AlertDialogCancel({
@@ -136,10 +136,10 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
);
}
export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};

View File

@@ -1,29 +1,29 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
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: {
variant: {
default: 'bg-card text-card-foreground',
default: "bg-card text-card-foreground",
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: {
variant: 'default',
variant: "default",
},
},
)
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
@@ -31,36 +31,36 @@ function Alert({
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
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,
)}
{...props}
/>
)
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
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,
)}
{...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({
...props
}: 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 AvatarPrimitive from '@radix-ui/react-avatar'
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Avatar({
className,
@@ -13,12 +13,12 @@ function Avatar({
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
)
);
}
function AvatarImage({
@@ -28,10 +28,10 @@ function AvatarImage({
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
className={cn("aspect-square size-full", className)}
{...props}
/>
)
);
}
function AvatarFallback({
@@ -42,12 +42,12 @@ function AvatarFallback({
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
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,
)}
{...props}
/>
)
);
}
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -1,38 +1,38 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
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: {
variant: {
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:
'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:
'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:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

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

View File

@@ -1,8 +1,8 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
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",
@@ -10,22 +10,22 @@ const buttonGroupVariants = cva(
variants: {
orientation: {
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:
'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: {
orientation: 'horizontal',
orientation: "horizontal",
},
},
)
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
@@ -34,17 +34,17 @@ function ButtonGroup({
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'div'
const Comp = asChild ? Slot : "div";
return (
<Comp
@@ -54,12 +54,12 @@ function ButtonGroupText({
)}
{...props}
/>
)
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
@@ -67,12 +67,12 @@ function ButtonGroupSeparator({
data-slot="button-group-separator"
orientation={orientation}
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,
)}
{...props}
/>
)
);
}
export {
@@ -80,4 +80,4 @@ export {
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
};

View File

@@ -1,40 +1,40 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
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",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
default: "bg-primary text-primary-foreground hover:bg-primary/90",
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:
'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:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
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',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
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",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function Button({
className,
@@ -42,11 +42,11 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button'
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -54,7 +54,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...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 {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react'
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
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\_previous>svg]:rotate-180`,
className,
@@ -37,91 +37,91 @@ function Calendar({
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
root: cn("w-fit", defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
"flex gap-4 flex-col md:flex-row relative",
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(
'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,
),
button_previous: cn(
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,
),
button_next: cn(
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,
),
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,
),
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,
),
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,
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? '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',
"select-none font-medium",
captionLayout === "label"
? "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",
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
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,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
"select-none w-(--cell-size)",
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number,
),
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,
),
range_start: cn(
'rounded-l-md bg-accent',
"rounded-l-md bg-accent",
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
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,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
@@ -133,27 +133,27 @@ function Calendar({
className={cn(className)}
{...props}
/>
)
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
)
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === 'right') {
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn('size-4', className)}
className={cn("size-4", className)}
{...props}
/>
)
);
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
)
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
@@ -163,13 +163,13 @@ function Calendar({
{children}
</div>
</td>
)
);
},
...components,
}}
{...props}
/>
)
);
}
function CalendarDayButton({
@@ -178,12 +178,12 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null)
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
@@ -201,13 +201,13 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
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,
className,
)}
{...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 (
<div
data-slot="card"
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,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
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,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
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}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

View File

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

View File

@@ -1,4 +1,4 @@
"use client"
"use client";
import {
ShoppingCart,
@@ -58,86 +58,90 @@ import {
Key,
Refrigerator,
type LucideIcon,
} from "lucide-react"
} from "lucide-react";
// Map icon names to Lucide components
const iconMap: Record<string, LucideIcon> = {
"shopping-cart": ShoppingCart,
"utensils": Utensils,
"croissant": Croissant,
"fuel": Fuel,
"train": Train,
"car": Car,
utensils: Utensils,
croissant: Croissant,
fuel: Fuel,
train: Train,
car: Car,
"car-taxi": Car, // Using Car as fallback for car-taxi
"car-key": Key, // Using Key as fallback
"parking": SquareParking,
"bike": Bike,
"plane": Plane,
"home": Home,
"zap": Zap,
"droplet": Droplet,
"hammer": Hammer,
"sofa": Sofa,
"refrigerator": Refrigerator,
"pill": Pill,
"stethoscope": Stethoscope,
"hospital": Hospital,
"glasses": Glasses,
"dumbbell": Dumbbell,
"sparkles": Sparkles,
"tv": Tv,
"music": Music,
"film": Film,
"gamepad": Gamepad,
"book": Book,
"ticket": Ticket,
"shirt": Shirt,
"smartphone": Smartphone,
"package": Package,
"wifi": Wifi,
"repeat": Repeat,
"landmark": Landmark,
"shield": Shield,
parking: SquareParking,
bike: Bike,
plane: Plane,
home: Home,
zap: Zap,
droplet: Droplet,
hammer: Hammer,
sofa: Sofa,
refrigerator: Refrigerator,
pill: Pill,
stethoscope: Stethoscope,
hospital: Hospital,
glasses: Glasses,
dumbbell: Dumbbell,
sparkles: Sparkles,
tv: Tv,
music: Music,
film: Film,
gamepad: Gamepad,
book: Book,
ticket: Ticket,
shirt: Shirt,
smartphone: Smartphone,
package: Package,
wifi: Wifi,
repeat: Repeat,
landmark: Landmark,
shield: Shield,
"heart-pulse": HeartPulse,
"receipt": Receipt,
receipt: Receipt,
"piggy-bank": PiggyBank,
"banknote": Banknote,
"wallet": Wallet,
banknote: Banknote,
wallet: Wallet,
"hand-coins": HandCoins,
"undo": Undo,
"coins": Coins,
"bed": Bed,
"luggage": Luggage,
undo: Undo,
coins: Coins,
bed: Bed,
luggage: Luggage,
"graduation-cap": GraduationCap,
"baby": Baby,
baby: Baby,
"paw-print": PawPrint,
"wrench": Wrench,
wrench: Wrench,
"heart-handshake": HeartHandshake,
"gift": Gift,
"cigarette": Cigarette,
gift: Gift,
cigarette: Cigarette,
"arrow-right-left": ArrowRightLeft,
"help-circle": HelpCircle,
"tag": Tag,
"folder": Folder,
}
tag: Tag,
folder: Folder,
};
// Get all available icon names
export const availableIcons = Object.keys(iconMap)
export const availableIcons = Object.keys(iconMap);
// Get the icon component by name
export function getIconComponent(iconName: string): LucideIcon {
return iconMap[iconName] || Tag
return iconMap[iconName] || Tag;
}
interface CategoryIconProps {
icon: string
color?: string
className?: string
size?: number
icon: string;
color?: string;
className?: string;
size?: number;
}
export function CategoryIcon({ icon, color, className, size = 20 }: CategoryIconProps) {
const IconComponent = getIconComponent(icon)
return <IconComponent className={className} style={{ color }} size={size} />
export function CategoryIcon({
icon,
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 RechartsPrimitive from 'recharts'
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
);
};
type ChartContextProps = {
config: ChartConfig
}
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null)
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext)
const context = React.useContext(ChartContext);
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({
@@ -40,14 +40,14 @@ function ChartContainer({
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children']
>["children"];
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
@@ -66,16 +66,16 @@ function ChartContainer({
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
)
);
if (!colorConfig.length) {
return null
return null;
}
return (
@@ -89,35 +89,35 @@ ${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
.join("\n")}
}
`,
)
.join('\n'),
.join("\n"),
}}
/>
)
}
);
};
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltip = RechartsPrimitive.Tooltip;
type TooltipPayloadItem = {
dataKey?: string | number
name?: string
value?: number | string
color?: string
payload?: Record<string, unknown> & { fill?: string }
fill?: string
}
dataKey?: string | number;
name?: string;
value?: number | string;
color?: string;
payload?: Record<string, unknown> & { fill?: string };
fill?: string;
};
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
@@ -127,44 +127,47 @@ function ChartTooltipContent({
color,
nameKey,
labelKey,
}: Omit<React.ComponentProps<typeof RechartsPrimitive.Tooltip>, 'payload' | 'label'> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
payload?: TooltipPayloadItem[]
label?: string | number
}: Omit<
React.ComponentProps<typeof RechartsPrimitive.Tooltip>,
"payload" | "label"
> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
payload?: TooltipPayloadItem[];
label?: string | number;
}) {
const { config } = useChart()
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
return null;
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
);
}
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,
labelFormatter,
@@ -173,38 +176,44 @@ function ChartTooltipContent({
labelClassName,
config,
labelKey,
])
]);
if (!active || !payload?.length) {
return null
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot'
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
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,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item: TooltipPayloadItem, index: number) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload?.fill || item.color
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'[&>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',
"[&>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",
)}
>
{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 ? (
@@ -213,19 +222,19 @@ function ChartTooltipContent({
!hideIndicator && (
<div
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',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
@@ -233,8 +242,8 @@ function ChartTooltipContent({
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
@@ -252,56 +261,56 @@ function ChartTooltipContent({
</>
)}
</div>
)
);
})}
</div>
</div>
)
);
}
const ChartLegend = RechartsPrimitive.Legend
const ChartLegend = RechartsPrimitive.Legend;
type LegendPayloadItem = {
value?: string
dataKey?: string | number
color?: string
}
value?: string;
dataKey?: string | number;
color?: string;
};
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<'div'> & {
hideIcon?: boolean
nameKey?: string
payload?: LegendPayloadItem[]
verticalAlign?: 'top' | 'bottom' | 'middle'
}) {
const { config } = useChart()
}: React.ComponentProps<"div"> & {
hideIcon?: boolean;
nameKey?: string;
payload?: LegendPayloadItem[];
verticalAlign?: "top" | "bottom" | "middle";
}) {
const { config } = useChart();
if (!payload?.length) {
return null
return null;
}
return (
<div
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item: LegendPayloadItem) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
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 ? (
@@ -316,10 +325,10 @@ function ChartLegendContent({
)}
{itemConfig?.label}
</div>
)
);
})}
</div>
)
);
}
// Helper to extract item config from a payload.
@@ -328,37 +337,37 @@ function getPayloadConfigFromPayload(
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
: undefined;
let configLabelKey: string = key
let configLabelKey: string = key;
if (
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 (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
: config[key as keyof typeof config];
}
export {
@@ -368,4 +377,4 @@ export {
ChartLegend,
ChartLegendContent,
ChartStyle,
}
};

View File

@@ -1,10 +1,10 @@
'use client'
"use client";
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Checkbox({
className,
@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
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,
)}
{...props}
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</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({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger"
{...props}
/>
)
);
}
function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content"
{...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 { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
} from "@/components/ui/dialog";
function Command({
className,
@@ -21,26 +21,26 @@ function Command({
<CommandPrimitive
data-slot="command"
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,
)}
{...props}
/>
)
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
@@ -49,7 +49,7 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
className={cn("overflow-hidden p-0", className)}
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">
@@ -57,7 +57,7 @@ function CommandDialog({
</Command>
</DialogContent>
</Dialog>
)
);
}
function CommandInput({
@@ -73,13 +73,13 @@ function CommandInput({
<CommandPrimitive.Input
data-slot="command-input"
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,
)}
{...props}
/>
</div>
)
);
}
function CommandList({
@@ -90,12 +90,12 @@ function CommandList({
<CommandPrimitive.List
data-slot="command-list"
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,
)}
{...props}
/>
)
);
}
function CommandEmpty({
@@ -107,7 +107,7 @@ function CommandEmpty({
className="py-6 text-center text-sm"
{...props}
/>
)
);
}
function CommandGroup({
@@ -118,12 +118,12 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
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,
)}
{...props}
/>
)
);
}
function CommandSeparator({
@@ -133,10 +133,10 @@ function CommandSeparator({
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
);
}
function CommandItem({
@@ -152,23 +152,23 @@ function CommandItem({
)}
{...props}
/>
)
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
)
);
}
export {
@@ -181,4 +181,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
}
};

View File

@@ -1,15 +1,15 @@
'use client'
"use client";
import * as React from 'react'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
@@ -17,7 +17,7 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
);
}
function ContextMenuGroup({
@@ -25,7 +25,7 @@ function ContextMenuGroup({
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
);
}
function ContextMenuPortal({
@@ -33,13 +33,13 @@ function ContextMenuPortal({
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
);
}
function ContextMenuSub({
...props
}: 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({
@@ -50,7 +50,7 @@ function ContextMenuRadioGroup({
data-slot="context-menu-radio-group"
{...props}
/>
)
);
}
function ContextMenuSubTrigger({
@@ -59,7 +59,7 @@ function ContextMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
@@ -74,7 +74,7 @@ function ContextMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
);
}
function ContextMenuSubContent({
@@ -85,12 +85,12 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
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,
)}
{...props}
/>
)
);
}
function ContextMenuContent({
@@ -102,23 +102,23 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
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,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
@@ -131,7 +131,7 @@ function ContextMenuItem({
)}
{...props}
/>
)
);
}
function ContextMenuCheckboxItem({
@@ -157,7 +157,7 @@ function ContextMenuCheckboxItem({
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
);
}
function ContextMenuRadioItem({
@@ -181,7 +181,7 @@ function ContextMenuRadioItem({
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
);
}
function ContextMenuLabel({
@@ -189,19 +189,19 @@ function ContextMenuLabel({
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
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,
)}
{...props}
/>
)
);
}
function ContextMenuSeparator({
@@ -211,26 +211,26 @@ function ContextMenuSeparator({
return (
<ContextMenuPrimitive.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}
/>
)
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
)
);
}
export {
@@ -249,4 +249,4 @@ export {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
};

View File

@@ -1,33 +1,33 @@
'use client'
"use client";
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@@ -38,12 +38,12 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
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,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
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,
)}
{...props}
@@ -77,30 +77,30 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
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}
/>
)
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
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,
)}
{...props}
/>
)
);
}
function DialogTitle({
@@ -110,10 +110,10 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -123,10 +123,10 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -140,4 +140,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};

View File

@@ -1,32 +1,32 @@
'use client'
"use client";
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
@@ -37,12 +37,12 @@ function DrawerOverlay({
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
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,
)}
{...props}
/>
)
);
}
function DrawerContent({
@@ -56,11 +56,11 @@ function DrawerContent({
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'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=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=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',
"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=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=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,
)}
{...props}
@@ -69,30 +69,30 @@ function DrawerContent({
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
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,
)}
{...props}
/>
)
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
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}
/>
)
);
}
function DrawerTitle({
@@ -102,10 +102,10 @@ function DrawerTitle({
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
);
}
function DrawerDescription({
@@ -115,10 +115,10 @@ function DrawerDescription({
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -132,4 +132,4 @@ export {
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
};

View File

@@ -1,15 +1,15 @@
'use client'
"use client";
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -42,13 +42,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -56,17 +56,17 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -79,7 +79,7 @@ function DropdownMenuItem({
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
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,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -170,32 +170,32 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.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}
/>
)
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: 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({
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -219,7 +219,7 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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,
)}
{...props}
/>
)
);
}
export {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
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 (
<div
data-slot="empty"
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,
)}
{...props}
/>
)
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
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,
)}
{...props}
/>
)
);
}
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: {
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",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function EmptyMedia({
className,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
@@ -55,43 +55,43 @@ function EmptyMedia({
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)}
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
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,
)}
{...props}
/>
)
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
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,
)}
{...props}
/>
)
);
}
export {
@@ -101,4 +101,4 @@ export {
EmptyDescription,
EmptyContent,
EmptyMedia,
}
};

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
'use client'
"use client";
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
@@ -16,12 +16,12 @@ function HoverCardTrigger({
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
);
}
function HoverCardContent({
className,
align = 'center',
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
@@ -32,13 +32,13 @@ function HoverCardContent({
align={align}
sideOffset={sideOffset}
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,
)}
{...props}
/>
</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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
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',
'h-9 has-[>textarea]:h-auto',
"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",
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-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-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
"has-[>[data-align=inline-start]]:[&>input]:pl-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-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// 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.
'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,
)}
{...props}
/>
)
);
}
const inputGroupAddonVariants = cva(
@@ -40,27 +40,27 @@ const inputGroupAddonVariants = cva(
{
variants: {
align: {
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: 'inline-start',
align: "inline-start",
},
},
)
);
function InputGroupAddon({
className,
align = 'inline-start',
align = "inline-start",
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
@@ -68,41 +68,41 @@ function InputGroupAddon({
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
)
);
}
const inputGroupButtonVariants = cva(
'text-sm shadow-none flex gap-2 items-center',
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
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',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: 'xs',
size: "xs",
},
},
)
);
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
@@ -112,10 +112,10 @@ function InputGroupButton({
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
@@ -124,39 +124,39 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
)}
{...props}
/>
)
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
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,
)}
{...props}
/>
)
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
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,
)}
{...props}
/>
)
);
}
export {
@@ -166,4 +166,4 @@ export {
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
};

View File

@@ -1,57 +1,57 @@
'use client'
"use client";
import * as React from 'react'
import { OTPInput, OTPInputContext } from 'input-otp'
import { MinusIcon } from 'lucide-react'
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn('flex items-center', className)}
className={cn("flex items-center", className)}
{...props}
/>
)
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
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,
)}
{...props}
@@ -63,15 +63,15 @@ function InputOTPSlot({
</div>
)}
</div>
)
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</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 (
<input
type={type}
data-slot="input"
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',
'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',
"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]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };

View File

@@ -1,19 +1,19 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn('group/item-group flex flex-col', className)}
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
);
}
function ItemSeparator({
@@ -24,42 +24,42 @@ function ItemSeparator({
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
className={cn("my-0", className)}
{...props}
/>
)
);
}
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: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5',
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function Item({
className,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<'div'> &
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="item"
@@ -68,31 +68,31 @@ function Item({
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
);
}
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: {
variant: {
default: 'bg-transparent',
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
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: {
variant: 'default',
variant: "default",
},
},
)
);
function ItemMedia({
className,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
@@ -100,83 +100,83 @@ function ItemMedia({
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
);
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
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,
)}
{...props}
/>
)
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
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,
)}
{...props}
/>
)
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
)
);
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn('flex items-center gap-2', className)}
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
'flex basis-full items-center justify-between gap-2',
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props}
/>
)
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
'flex basis-full items-center justify-between gap-2',
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props}
/>
)
);
}
export {
@@ -190,4 +190,4 @@ export {
ItemDescription,
ItemHeader,
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 (
<kbd
data-slot="kbd"
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",
'[[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,
)}
{...props}
/>
)
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
);
}
export { Kbd, KbdGroup }
export { Kbd, KbdGroup };

View File

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

View File

@@ -1,10 +1,10 @@
'use client'
"use client";
import * as React from 'react'
import * as MenubarPrimitive from '@radix-ui/react-menubar'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Menubar({
className,
@@ -14,30 +14,30 @@ function Menubar({
<MenubarPrimitive.Root
data-slot="menubar"
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,
)}
{...props}
/>
)
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
@@ -45,7 +45,7 @@ function MenubarRadioGroup({
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
);
}
function MenubarTrigger({
@@ -56,17 +56,17 @@ function MenubarTrigger({
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
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,
)}
{...props}
/>
)
);
}
function MenubarContent({
className,
align = 'start',
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
@@ -79,23 +79,23 @@ function MenubarContent({
alignOffset={alignOffset}
sideOffset={sideOffset}
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,
)}
{...props}
/>
</MenubarPortal>
)
);
}
function MenubarItem({
className,
inset,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
@@ -108,7 +108,7 @@ function MenubarItem({
)}
{...props}
/>
)
);
}
function MenubarCheckboxItem({
@@ -134,7 +134,7 @@ function MenubarCheckboxItem({
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
);
}
function MenubarRadioItem({
@@ -158,7 +158,7 @@ function MenubarRadioItem({
</span>
{children}
</MenubarPrimitive.RadioItem>
)
);
}
function MenubarLabel({
@@ -166,19 +166,19 @@ function MenubarLabel({
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
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,
)}
{...props}
/>
)
);
}
function MenubarSeparator({
@@ -188,32 +188,32 @@ function MenubarSeparator({
return (
<MenubarPrimitive.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}
/>
)
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
)
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
@@ -222,14 +222,14 @@ function MenubarSubTrigger({
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
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,
)}
{...props}
@@ -237,7 +237,7 @@ function MenubarSubTrigger({
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
);
}
function MenubarSubContent({
@@ -248,12 +248,12 @@ function MenubarSubContent({
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
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,
)}
{...props}
/>
)
);
}
export {
@@ -273,4 +273,4 @@ export {
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
};

View File

@@ -1,9 +1,9 @@
import * as React from 'react'
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
import { cva } from 'class-variance-authority'
import { ChevronDownIcon } from 'lucide-react'
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function NavigationMenu({
className,
@@ -11,14 +11,14 @@ function NavigationMenu({
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
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,
)}
{...props}
@@ -26,7 +26,7 @@ function NavigationMenu({
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
);
}
function NavigationMenuList({
@@ -37,12 +37,12 @@ function NavigationMenuList({
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
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,
)}
{...props}
/>
)
);
}
function NavigationMenuItem({
@@ -52,15 +52,15 @@ function NavigationMenuItem({
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn('relative', className)}
className={cn("relative", className)}
{...props}
/>
)
);
}
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({
className,
@@ -70,16 +70,16 @@ function NavigationMenuTrigger({
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), 'group', className)}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{' '}
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
);
}
function NavigationMenuContent({
@@ -90,13 +90,13 @@ function NavigationMenuContent({
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
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',
'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',
"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",
className,
)}
{...props}
/>
)
);
}
function NavigationMenuViewport({
@@ -105,18 +105,18 @@ function NavigationMenuViewport({
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<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
data-slot="navigation-menu-viewport"
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,
)}
{...props}
/>
</div>
)
);
}
function NavigationMenuLink({
@@ -132,7 +132,7 @@ function NavigationMenuLink({
)}
{...props}
/>
)
);
}
function NavigationMenuIndicator({
@@ -143,14 +143,14 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
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,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
);
}
export {
@@ -163,4 +163,4 @@ export {
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
};

View File

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

View File

@@ -1,25 +1,25 @@
'use client'
"use client";
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
@@ -30,19 +30,19 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
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,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
);
}
function PopoverAnchor({
...props
}: 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 ProgressPrimitive from '@radix-ui/react-progress'
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Progress({
className,
@@ -14,7 +14,7 @@ function Progress({
<ProgressPrimitive.Root
data-slot="progress"
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,
)}
{...props}
@@ -25,7 +25,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
);
}
export { Progress }
export { Progress };

View File

@@ -1,10 +1,10 @@
'use client'
"use client";
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { CircleIcon } from 'lucide-react'
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function RadioGroup({
className,
@@ -13,10 +13,10 @@ function RadioGroup({
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
className={cn("grid gap-3", className)}
{...props}
/>
)
);
}
function RadioGroupItem({
@@ -27,7 +27,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
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,
)}
{...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" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
);
}
export { RadioGroup, RadioGroupItem }
export { RadioGroup, RadioGroupItem };

View File

@@ -1,10 +1,10 @@
'use client'
"use client";
import * as React from 'react'
import { GripVerticalIcon } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
@@ -14,18 +14,18 @@ function ResizablePanelGroup({
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
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,
)}
{...props}
/>
)
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
@@ -33,13 +33,13 @@ function ResizableHandle({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
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,
)}
{...props}
@@ -50,7 +50,7 @@ function ResizableHandle({
</div>
)}
</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 ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function ScrollArea({
className,
@@ -13,7 +13,7 @@ function ScrollArea({
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
@@ -25,12 +25,12 @@ function ScrollArea({
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
);
}
function ScrollBar({
className,
orientation = 'vertical',
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
@@ -38,11 +38,11 @@ function ScrollBar({
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
@@ -52,7 +52,7 @@ function ScrollBar({
className="bg-border relative flex-1 rounded-full"
/>
</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 SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -47,13 +47,13 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
className,
children,
position = 'popper',
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
@@ -61,9 +61,9 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
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',
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',
"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" &&
"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,
)}
position={position}
@@ -72,9 +72,9 @@ function SelectContent({
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
@@ -82,7 +82,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@@ -92,10 +92,10 @@ function SelectLabel({
return (
<SelectPrimitive.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}
/>
)
);
}
function SelectItem({
@@ -119,7 +119,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -129,10 +129,10 @@ function SelectSeparator({
return (
<SelectPrimitive.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}
/>
)
);
}
function SelectScrollUpButton({
@@ -143,14 +143,14 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -161,14 +161,14 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -182,4 +182,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};

View File

@@ -1,13 +1,13 @@
'use client'
"use client";
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = 'horizontal',
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@@ -17,12 +17,12 @@ function Separator({
decorative={decorative}
orientation={orientation}
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,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };

View File

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

View File

@@ -1,56 +1,56 @@
'use client'
"use client";
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
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({
@@ -61,37 +61,37 @@ function SidebarProvider({
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// 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],
)
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -100,18 +100,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// 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.
const state = open ? 'expanded' : 'collapsed'
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -124,7 +124,7 @@ function SidebarProvider({
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
)
);
return (
<SidebarContext.Provider value={contextValue}>
@@ -133,13 +133,13 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
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,
)}
{...props}
@@ -148,36 +148,36 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
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,
)}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
@@ -190,7 +190,7 @@ function Sidebar({
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
@@ -202,14 +202,14 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
@@ -218,25 +218,25 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[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.
variant === 'floating' || variant === 'inset'
? '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',
variant === "floating" || variant === "inset"
? "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",
className,
)}
{...props}
@@ -250,7 +250,7 @@ function Sidebar({
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
@@ -258,7 +258,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<Button
@@ -266,21 +266,21 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn('size-7', className)}
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
@@ -291,31 +291,31 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
onClick={toggleSidebar}
title="Toggle Sidebar"
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',
'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',
'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=right][data-collapsible=offcanvas]_&]:-left-2',
"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",
"[[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",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'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',
"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",
className,
)}
{...props}
/>
)
);
}
function SidebarInput({
@@ -326,32 +326,32 @@ function SidebarInput({
<Input
data-slot="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}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="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}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="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}
/>
)
);
}
function SidebarSeparator({
@@ -362,154 +362,154 @@ function SidebarSeparator({
<Separator
data-slot="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}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
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,
)}
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="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}
/>
)
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
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',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-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",
className,
)}
{...props}
/>
)
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button'
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
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.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
className={cn("w-full text-sm", className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="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}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
className={cn("group/menu-item relative", className)}
{...props}
/>
)
);
}
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: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
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: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button'
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
@@ -520,16 +520,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === 'string') {
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
@@ -538,11 +538,11 @@ function SidebarMenuButton({
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -550,72 +550,72 @@ function SidebarMenuAction({
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
showOnHover?: boolean
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button'
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
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.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
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,
)}
{...props}
/>
)
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
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',
'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=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
"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-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="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}
>
{showIcon && (
@@ -629,55 +629,55 @@ function SidebarMenuSkeleton({
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
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',
'group-data-[collapsible=icon]:hidden',
"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",
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
}: React.ComponentProps<"li">) {
return (
<li
data-slot="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}
/>
)
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a'
const Comp = asChild ? Slot : "a";
return (
<Comp
@@ -686,16 +686,16 @@ function SidebarMenuSubButton({
data-size={size}
data-active={isActive}
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',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
"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",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
);
}
export {
@@ -723,4 +723,4 @@ export {
SidebarSeparator,
SidebarTrigger,
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 (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };

View File

@@ -1,9 +1,9 @@
'use client'
"use client";
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Slider({
className,
@@ -21,7 +21,7 @@ function Slider({
? defaultValue
: [min, max],
[value, defaultValue, min, max],
)
);
return (
<SliderPrimitive.Root
@@ -31,7 +31,7 @@ function Slider({
min={min}
max={max}
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,
)}
{...props}
@@ -39,13 +39,13 @@ function Slider({
<SliderPrimitive.Track
data-slot="slider-track"
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
data-slot="slider-range"
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>
@@ -57,7 +57,7 @@ function Slider({
/>
))}
</SliderPrimitive.Root>
)
);
}
export { Slider }
export { Slider };

View File

@@ -1,25 +1,25 @@
'use client'
"use client";
import { useTheme } from 'next-themes'
import { Toaster as Sonner, ToasterProps } from 'sonner'
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...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 (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn('size-4 animate-spin', className)}
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
);
}
export { Spinner }
export { Spinner };

View File

@@ -1,9 +1,9 @@
'use client'
"use client";
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Switch({
className,
@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
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,
)}
{...props}
@@ -21,11 +21,11 @@ function Switch({
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
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>
)
);
}
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 (
<div
data-slot="table-container"
@@ -12,96 +12,96 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
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,
)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
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,
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
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,
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
)
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
}: React.ComponentProps<"caption">) {
return (
<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}
/>
)
);
}
export {
@@ -113,4 +113,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View File

@@ -1,9 +1,9 @@
'use client'
"use client";
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function Tabs({
className,
@@ -12,10 +12,10 @@ function Tabs({
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
);
}
function TabsList({
@@ -26,12 +26,12 @@ function TabsList({
<TabsPrimitive.List
data-slot="tabs-list"
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,
)}
{...props}
/>
)
);
}
function TabsTrigger({
@@ -47,7 +47,7 @@ function TabsTrigger({
)}
{...props}
/>
)
);
}
function TabsContent({
@@ -57,10 +57,10 @@ function TabsContent({
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
className={cn("flex-1 outline-none", className)}
{...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 (
<textarea
data-slot="textarea"
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,
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };

View File

@@ -1,13 +1,13 @@
'use client'
"use client";
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
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<
React.ElementRef<typeof ToastPrimitives.Viewport>,
@@ -16,29 +16,29 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
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,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
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: {
variant: {
default: 'border bg-background text-foreground',
default: "border bg-background text-foreground",
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
@@ -51,9 +51,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
@@ -62,13 +62,13 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
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,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
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,
)}
toast-close=""
@@ -85,8 +85,8 @@ const ToastClose = React.forwardRef<
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
@@ -94,11 +94,11 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
@@ -106,15 +106,15 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
className={cn("text-sm opacity-90", className)}
{...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 {
type ToastProps,
@@ -126,4 +126,4 @@ export {
ToastDescription,
ToastClose,
ToastAction,
}
};

View File

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

View File

@@ -1,18 +1,18 @@
'use client'
"use client";
import * as React from 'react'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import { type VariantProps } from 'class-variance-authority'
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
})
size: "default",
variant: "default",
});
function ToggleGroup({
className,
@@ -28,7 +28,7 @@ function ToggleGroup({
data-variant={variant}
data-size={size}
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,
)}
{...props}
@@ -37,7 +37,7 @@ function ToggleGroup({
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
);
}
function ToggleGroupItem({
@@ -48,7 +48,7 @@ function ToggleGroupItem({
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
@@ -60,14 +60,14 @@ function ToggleGroupItem({
variant: context.variant || variant,
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,
)}
{...props}
>
{children}
</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 TogglePrimitive from '@radix-ui/react-toggle'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
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",
{
variants: {
variant: {
default: 'bg-transparent',
default: "bg-transparent",
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: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function Toggle({
className,
@@ -41,7 +41,7 @@ function Toggle({
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Toggle, toggleVariants }
export { Toggle, toggleVariants };

View File

@@ -1,9 +1,9 @@
'use client'
"use client";
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -46,7 +46,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
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,
)}
{...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.Content>
</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() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
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
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_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
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) => {
if (toastTimeouts.has(toastId)) {
return
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
};
case 'UPDATE_TOAST':
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
};
case 'DISMISS_TOAST': {
const { toastId } = action
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
addToRemoveQueue(toast.id);
});
}
return {
@@ -110,82 +110,82 @@ export const reducer = (state: State, action: Action): State => {
}
: t,
),
}
};
}
case 'REMOVE_TOAST':
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
};
}
return {
...state,
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) {
memoryState = reducer(memoryState, action)
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState)
})
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: 'ADD_TOAST',
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
if (!open) dismiss();
},
},
})
});
return {
id: id,
dismiss,
update,
}
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState)
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState)
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1);
}
}
}, [state])
};
}, [state]);
return {
...state,
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 reactPlugin from 'eslint-plugin-react'
import hooksPlugin from 'eslint-plugin-react-hooks'
import tseslint from 'typescript-eslint'
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default [
{
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**'],
ignores: ["node_modules/**", ".next/**", "out/**", "build/**"],
},
...tseslint.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: {
'@next/next': nextPlugin,
"@next/next": nextPlugin,
react: reactPlugin,
'react-hooks': hooksPlugin,
"react-hooks": hooksPlugin,
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules,
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
...nextPlugin.configs["core-web-vitals"].rules,
"react/react-in-jsx-scope": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
'@typescript-eslint/no-explicit-any': 'warn',
"@typescript-eslint/no-explicit-any": "warn",
},
settings: {
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() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
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
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_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
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) => {
if (toastTimeouts.has(toastId)) {
return
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
};
case 'UPDATE_TOAST':
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
};
case 'DISMISS_TOAST': {
const { toastId } = action
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
addToRemoveQueue(toast.id);
});
}
return {
@@ -110,82 +110,82 @@ export const reducer = (state: State, action: Action): State => {
}
: t,
),
}
};
}
case 'REMOVE_TOAST':
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
};
}
return {
...state,
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) {
memoryState = reducer(memoryState, action)
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState)
})
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: 'ADD_TOAST',
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
if (!open) dismiss();
},
},
})
});
return {
id: id,
dismiss,
update,
}
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState)
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState)
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1);
}
}
}, [state])
};
}, [state]);
return {
...state,
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 type { BankingData } from "./types"
import { loadData } from "./store-db"
import { useState, useEffect, useCallback } from "react";
import type { BankingData } from "./types";
import { loadData } from "./store-db";
export function useBankingData() {
const [data, setData] = useState<BankingData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [data, setData] = useState<BankingData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const fetchedData = await loadData()
setData(fetchedData)
setIsLoading(true);
setError(null);
const fetchedData = await loadData();
setData(fetchedData);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load data"))
console.error("Error loading banking data:", err)
setError(err instanceof Error ? err : new Error("Failed to load data"));
console.error("Error loading banking data:", err);
} finally {
setIsLoading(false)
setIsLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
const refresh = useCallback(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
const update = useCallback((newData: BankingData) => {
// 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) {
const [storedValue, setStoredValue] = useState<T>(initialValue)
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key)
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item))
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.error(error)
console.error(error);
}
}, [key])
}, [key]);
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} 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 {
try {
// Remove SGML header and clean up
const xmlStart = content.indexOf("<OFX>")
if (xmlStart === -1) return null
const xmlStart = content.indexOf("<OFX>");
if (xmlStart === -1) return null;
let xml = content.substring(xmlStart)
let xml = content.substring(xmlStart);
// 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
const bankId = extractValue(xml, "BANKID") || extractValue(xml, "ORG") || "UNKNOWN"
const accountId = extractValue(xml, "ACCTID") || "UNKNOWN"
const accountType = extractValue(xml, "ACCTTYPE") || "CHECKING"
const balanceStr = extractValue(xml, "BALAMT") || "0"
const balance = Number.parseFloat(balanceStr)
const balanceDate = extractValue(xml, "DTASOF") || new Date().toISOString()
const currency = extractValue(xml, "CURDEF") || "EUR"
const bankId =
extractValue(xml, "BANKID") || extractValue(xml, "ORG") || "UNKNOWN";
const accountId = extractValue(xml, "ACCTID") || "UNKNOWN";
const accountType = extractValue(xml, "ACCTTYPE") || "CHECKING";
const balanceStr = extractValue(xml, "BALAMT") || "0";
const balance = Number.parseFloat(balanceStr);
const balanceDate = extractValue(xml, "DTASOF") || new Date().toISOString();
const currency = extractValue(xml, "CURDEF") || "EUR";
// Extract transactions
const transactions: OFXTransaction[] = []
const stmtTrnRegex = /<STMTTRN>([\s\S]*?)<\/STMTTRN>/gi
let match
const transactions: OFXTransaction[] = [];
const stmtTrnRegex = /<STMTTRN>([\s\S]*?)<\/STMTTRN>/gi;
let match;
while ((match = stmtTrnRegex.exec(xml)) !== null) {
const trnXml = match[1]
const trnXml = match[1];
const fitId = extractValue(trnXml, "FITID") || `${Date.now()}-${Math.random()}`
const dateStr = extractValue(trnXml, "DTPOSTED") || ""
const amountStr = extractValue(trnXml, "TRNAMT") || "0"
const name = extractValue(trnXml, "NAME") || extractValue(trnXml, "MEMO") || "Unknown"
const memo = extractValue(trnXml, "MEMO")
const checkNum = extractValue(trnXml, "CHECKNUM")
const type = extractValue(trnXml, "TRNTYPE") || "OTHER"
const fitId =
extractValue(trnXml, "FITID") || `${Date.now()}-${Math.random()}`;
const dateStr = extractValue(trnXml, "DTPOSTED") || "";
const amountStr = extractValue(trnXml, "TRNAMT") || "0";
const name =
extractValue(trnXml, "NAME") ||
extractValue(trnXml, "MEMO") ||
"Unknown";
const memo = extractValue(trnXml, "MEMO");
const checkNum = extractValue(trnXml, "CHECKNUM");
const type = extractValue(trnXml, "TRNTYPE") || "OTHER";
transactions.push({
fitId,
@@ -44,7 +49,7 @@ export function parseOFX(content: string): OFXAccount | null {
memo: memo ? cleanString(memo) : undefined,
checkNum: checkNum ?? undefined,
type,
})
});
}
return {
@@ -55,37 +60,39 @@ export function parseOFX(content: string): OFXAccount | null {
balanceDate: parseOFXDate(balanceDate),
currency,
transactions,
}
};
} catch (error) {
console.error("Error parsing OFX:", error)
return null
console.error("Error parsing OFX:", error);
return null;
}
}
function extractValue(xml: string, tag: string): string | null {
const regex = new RegExp(`<${tag}>([^<]+)`, "i")
const match = xml.match(regex)
return match ? match[1].trim() : null
const regex = new RegExp(`<${tag}>([^<]+)`, "i");
const match = xml.match(regex);
return match ? match[1].trim() : null;
}
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 month = dateStr.substring(4, 6)
const day = dateStr.substring(6, 8)
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return `${year}-${month}-${day}`
return `${year}-${month}-${day}`;
}
function mapAccountType(type: string): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
const upper = type.toUpperCase()
if (upper.includes("CHECK") || upper.includes("CURRENT")) return "CHECKING"
if (upper.includes("SAV")) return "SAVINGS"
if (upper.includes("CREDIT")) return "CREDIT_CARD"
return "OTHER"
function mapAccountType(
type: string,
): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
const upper = type.toUpperCase();
if (upper.includes("CHECK") || upper.includes("CURRENT")) return "CHECKING";
if (upper.includes("SAV")) return "SAVINGS";
if (upper.includes("CREDIT")) return "CREDIT_CARD";
return "OTHER";
}
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 {
prisma: PrismaClient | undefined
}
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
})
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["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> {
const response = await fetch(API_BASE)
const response = await fetch(API_BASE);
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(account),
})
});
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> {
@@ -29,44 +37,48 @@ export async function updateAccount(account: Account): Promise<Account> {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(account),
})
});
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> {
const response = await fetch(`${API_BASE}/accounts?id=${accountId}`, {
method: "DELETE",
})
});
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transactions),
})
});
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`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transaction),
})
});
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> {
@@ -74,11 +86,11 @@ export async function addFolder(folder: Omit<Folder, "id">): Promise<Folder> {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(folder),
})
});
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> {
@@ -86,32 +98,34 @@ export async function updateFolder(folder: Folder): Promise<Folder> {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(folder),
})
});
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> {
const response = await fetch(`${API_BASE}/folders?id=${folderId}`, {
method: "DELETE",
})
});
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(category),
})
});
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> {
@@ -119,55 +133,57 @@ export async function updateCategory(category: Category): Promise<Category> {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(category),
})
});
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> {
const response = await fetch(`${API_BASE}/categories?id=${categoryId}`, {
method: "DELETE",
})
});
if (!response.ok) {
throw new Error("Failed to delete category")
throw new Error("Failed to delete category");
}
}
// Auto-categorize a transaction based on keywords
export function autoCategorize(description: string, categories: Category[]): string | null {
const lowerDesc = description.toLowerCase()
export function autoCategorize(
description: string,
categories: Category[],
): string | null {
const lowerDesc = description.toLowerCase();
for (const category of categories) {
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
// Évite les faux positifs comme "chat" dans "achat"
if (lowerKeyword.length < 6) {
const wordBoundary = new RegExp(`\\b${escapeRegex(lowerKeyword)}\\b`)
const wordBoundary = new RegExp(`\\b${escapeRegex(lowerKeyword)}\\b`);
if (wordBoundary.test(lowerDesc)) {
return category.id
return category.id;
}
} else {
// Pour les keywords plus longs, includes() suffit
if (lowerDesc.includes(lowerKeyword)) {
return category.id
return category.id;
}
}
}
}
return null
return null;
}
// Échappe les caractères spéciaux pour les regex
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
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 { defaultCategories, defaultRootFolder } from "./defaults"
import type {
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
function buildCategoriesFromDefaults(): Category[] {
const slugToId = new Map<string, string>()
const categories: Category[] = []
const slugToId = new Map<string, string>();
const categories: Category[] = [];
// D'abord les parents
const parents = defaultCategories.filter((c) => c.parentSlug === null)
const parents = defaultCategories.filter((c) => c.parentSlug === null);
parents.forEach((cat, index) => {
const id = `cat-${index + 1}`
slugToId.set(cat.slug, id)
const id = `cat-${index + 1}`;
slugToId.set(cat.slug, id);
categories.push({
id,
name: cat.name,
@@ -22,14 +28,14 @@ function buildCategoriesFromDefaults(): Category[] {
icon: cat.icon,
keywords: cat.keywords,
parentId: null,
})
})
});
});
// Puis les enfants
const children = defaultCategories.filter((c) => c.parentSlug !== null)
const children = defaultCategories.filter((c) => c.parentSlug !== null);
children.forEach((cat, index) => {
const id = `cat-${parents.length + index + 1}`
slugToId.set(cat.slug, id)
const id = `cat-${parents.length + index + 1}`;
slugToId.set(cat.slug, id);
categories.push({
id,
name: cat.name,
@@ -37,10 +43,10 @@ function buildCategoriesFromDefaults(): Category[] {
icon: cat.icon,
keywords: cat.keywords,
parentId: cat.parentSlug ? slugToId.get(cat.parentSlug) || null : null,
})
})
});
});
return categories
return categories;
}
const defaultData: BankingData = {
@@ -48,148 +54,163 @@ const defaultData: BankingData = {
transactions: [],
folders: [defaultRootFolder],
categories: buildCategoriesFromDefaults(),
}
};
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) {
saveData(defaultData)
return defaultData
saveData(defaultData);
return defaultData;
}
try {
return JSON.parse(stored)
return JSON.parse(stored);
} catch {
return defaultData
return defaultData;
}
}
export function saveData(data: BankingData): void {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
export function addAccount(account: Account): BankingData {
const data = loadData()
data.accounts.push(account)
saveData(data)
return data
const data = loadData();
data.accounts.push(account);
saveData(data);
return data;
}
export function updateAccount(account: Account): BankingData {
const data = loadData()
const index = data.accounts.findIndex((a) => a.id === account.id)
const data = loadData();
const index = data.accounts.findIndex((a) => a.id === account.id);
if (index !== -1) {
data.accounts[index] = account
saveData(data)
data.accounts[index] = account;
saveData(data);
}
return data
return data;
}
export function deleteAccount(accountId: string): BankingData {
const data = loadData()
data.accounts = data.accounts.filter((a) => a.id !== accountId)
data.transactions = data.transactions.filter((t) => t.accountId !== accountId)
saveData(data)
return data
const data = loadData();
data.accounts = data.accounts.filter((a) => a.id !== accountId);
data.transactions = data.transactions.filter(
(t) => t.accountId !== accountId,
);
saveData(data);
return data;
}
export function addTransactions(transactions: Transaction[]): BankingData {
const data = loadData()
const data = loadData();
// Filter out duplicates based on fitId
const existingFitIds = new Set(data.transactions.map((t) => `${t.accountId}-${t.fitId}`))
const newTransactions = transactions.filter((t) => !existingFitIds.has(`${t.accountId}-${t.fitId}`))
const existingFitIds = new Set(
data.transactions.map((t) => `${t.accountId}-${t.fitId}`),
);
const newTransactions = transactions.filter(
(t) => !existingFitIds.has(`${t.accountId}-${t.fitId}`),
);
data.transactions.push(...newTransactions)
saveData(data)
return data
data.transactions.push(...newTransactions);
saveData(data);
return data;
}
export function updateTransaction(transaction: Transaction): BankingData {
const data = loadData()
const index = data.transactions.findIndex((t) => t.id === transaction.id)
const data = loadData();
const index = data.transactions.findIndex((t) => t.id === transaction.id);
if (index !== -1) {
data.transactions[index] = transaction
saveData(data)
data.transactions[index] = transaction;
saveData(data);
}
return data
return data;
}
export function addFolder(folder: Folder): BankingData {
const data = loadData()
data.folders.push(folder)
saveData(data)
return data
const data = loadData();
data.folders.push(folder);
saveData(data);
return data;
}
export function updateFolder(folder: Folder): BankingData {
const data = loadData()
const index = data.folders.findIndex((f) => f.id === folder.id)
const data = loadData();
const index = data.folders.findIndex((f) => f.id === folder.id);
if (index !== -1) {
data.folders[index] = folder
saveData(data)
data.folders[index] = folder;
saveData(data);
}
return data
return data;
}
export function deleteFolder(folderId: string): BankingData {
const data = loadData()
const data = loadData();
// 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
const folder = data.folders.find((f) => f.id === folderId)
const folder = data.folders.find((f) => f.id === folderId);
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)
saveData(data)
return data
data.folders = data.folders.filter((f) => f.id !== folderId);
saveData(data);
return data;
}
export function addCategory(category: Category): BankingData {
const data = loadData()
data.categories.push(category)
saveData(data)
return data
const data = loadData();
data.categories.push(category);
saveData(data);
return data;
}
export function updateCategory(category: Category): BankingData {
const data = loadData()
const index = data.categories.findIndex((c) => c.id === category.id)
const data = loadData();
const index = data.categories.findIndex((c) => c.id === category.id);
if (index !== -1) {
data.categories[index] = category
saveData(data)
data.categories[index] = category;
saveData(data);
}
return data
return data;
}
export function deleteCategory(categoryId: string): BankingData {
const data = loadData()
data.categories = data.categories.filter((c) => c.id !== categoryId)
const data = loadData();
data.categories = data.categories.filter((c) => c.id !== categoryId);
// Remove category from transactions
data.transactions = data.transactions.map((t) => (t.categoryId === categoryId ? { ...t, categoryId: null } : t))
saveData(data)
return data
data.transactions = data.transactions.map((t) =>
t.categoryId === categoryId ? { ...t, categoryId: null } : t,
);
saveData(data);
return data;
}
// Auto-categorize a transaction based on keywords
export function autoCategorize(description: string, categories: Category[]): string | null {
const lowerDesc = description.toLowerCase()
export function autoCategorize(
description: string,
categories: Category[],
): string | null {
const lowerDesc = description.toLowerCase();
for (const category of categories) {
for (const keyword of category.keywords) {
if (lowerDesc.includes(keyword.toLowerCase())) {
return category.id
return category.id;
}
}
}
return null
return null;
}
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
export interface Transaction {
id: string
accountId: string
date: string
amount: number
description: string
type: "DEBIT" | "CREDIT"
categoryId: string | null
isReconciled: boolean
fitId: string // OFX unique transaction ID
memo?: string
checkNum?: string
id: string;
accountId: string;
date: string;
amount: number;
description: string;
type: "DEBIT" | "CREDIT";
categoryId: string | null;
isReconciled: boolean;
fitId: string; // OFX unique transaction ID
memo?: string;
checkNum?: string;
}
export interface Account {
id: string
name: string
bankId: string
accountNumber: string
type: "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER"
folderId: string | null
balance: number
currency: string
lastImport: string | null
id: string;
name: string;
bankId: string;
accountNumber: string;
type: "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER";
folderId: string | null;
balance: number;
currency: string;
lastImport: string | null;
}
export interface Folder {
id: string
name: string
parentId: string | null
color: string
icon: string
id: string;
name: string;
parentId: string | null;
color: string;
icon: string;
}
export interface Category {
id: string
name: string
color: string
icon: string
keywords: string[] // For auto-categorization
parentId: string | null
id: string;
name: string;
color: string;
icon: string;
keywords: string[]; // For auto-categorization
parentId: string | null;
}
export interface BankingData {
accounts: Account[]
transactions: Transaction[]
folders: Folder[]
categories: Category[]
accounts: Account[];
transactions: Transaction[];
folders: Folder[];
categories: Category[];
}
// OFX Parsed types
export interface OFXTransaction {
fitId: string
date: string
amount: number
name: string
memo?: string
checkNum?: string
type: string
fitId: string;
date: string;
amount: number;
name: string;
memo?: string;
checkNum?: string;
type: string;
}
export interface OFXAccount {
bankId: string
accountId: string
accountType: string
balance: number
balanceDate: string
currency: string
transactions: OFXTransaction[]
bankId: string;
accountId: string;
accountType: string;
balance: number;
balanceDate: string;
currency: string;
transactions: OFXTransaction[];
}

View File

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

View File

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

View File

@@ -84,10 +84,11 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"postcss": "^8.5",
"prettier": "^3.7.0",
"tailwindcss": "^4.1.9",
"tsx": "^4.20.6",
"tw-animate-css": "1.3.3",
"typescript": "^5",
"typescript-eslint": "^8.48.0"
}
}
}

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} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
"@tailwindcss/postcss": {},
},
}
};
export default config
export default config;

View File

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

View File

@@ -1,13 +1,12 @@
// Script simple pour initialiser la base de données
// 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("1. Lancez Prisma Studio: pnpm db:studio")
console.log("2. Créez manuellement le dossier racine 'folder-root' avec:")
console.log(" - id: folder-root")
console.log(" - name: Mes Comptes")
console.log(" - parentId: null")
console.log(" - color: #6366f1")
console.log(" - icon: folder")
console.log("3. Créez les catégories par défaut via l'interface web")
console.log("Pour initialiser la base de données:");
console.log("1. Lancez Prisma Studio: pnpm db:studio");
console.log("2. Créez manuellement le dossier racine 'folder-root' avec:");
console.log(" - id: folder-root");
console.log(" - name: Mes Comptes");
console.log(" - parentId: null");
console.log(" - color: #6366f1");
console.log(" - icon: folder");
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 { defaultCategories, type CategoryDefinition } from "../lib/defaults"
import { PrismaClient } from "@prisma/client";
import { defaultCategories, type CategoryDefinition } from "../lib/defaults";
const prisma = new PrismaClient()
const prisma = new PrismaClient();
async function main() {
console.log("🏷️ Synchronisation des catégories hiérarchiques...")
console.log(` ${defaultCategories.length} catégories à synchroniser\n`)
console.log("🏷️ Synchronisation des catégories hiérarchiques...");
console.log(` ${defaultCategories.length} catégories à synchroniser\n`);
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 0: Nettoyage des doublons existants
// ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50))
console.log("PHASE 0: Nettoyage des doublons")
console.log("═".repeat(50))
console.log("═".repeat(50));
console.log("PHASE 0: Nettoyage des doublons");
console.log("═".repeat(50));
const allExisting = await prisma.category.findMany()
const byNormalizedName = new Map<string, typeof allExisting>()
const allExisting = await prisma.category.findMany();
const byNormalizedName = new Map<string, typeof allExisting>();
for (const cat of allExisting) {
const normalized = normalizeName(cat.name)
const normalized = normalizeName(cat.name);
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) {
if (cats.length > 1) {
// Garder celui avec emoji
let keeper = cats[0]
let keeper = cats[0];
for (const cat of cats) {
if (/[\u{1F300}-\u{1F9FF}]/u.test(cat.name)) {
keeper = cat
break
keeper = cat;
break;
}
}
const toDelete: typeof cats = []
const toDelete: typeof 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) {
@@ -46,92 +46,109 @@ async function main() {
await prisma.transaction.updateMany({
where: { categoryId: dup.id },
data: { categoryId: keeper.id },
})
});
// Transférer enfants
await prisma.category.updateMany({
where: { parentId: dup.id },
data: { parentId: keeper.id },
})
});
// Supprimer doublon
await prisma.category.delete({ where: { id: dup.id } })
console.log(`🗑️ Fusionné: "${dup.name}" → "${keeper.name}"`)
merged++
await prisma.category.delete({ where: { id: dup.id } });
console.log(`🗑️ Fusionné: "${dup.name}" → "${keeper.name}"`);
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
const parentCategories = defaultCategories.filter((c) => c.parentSlug === null)
const childCategories = defaultCategories.filter((c) => c.parentSlug !== null)
const parentCategories = defaultCategories.filter(
(c) => c.parentSlug === null,
);
const childCategories = defaultCategories.filter(
(c) => c.parentSlug !== null,
);
console.log(`\n📁 ${parentCategories.length} catégories parentes`)
console.log(` └─ ${childCategories.length} sous-catégories\n`)
console.log(`\n📁 ${parentCategories.length} catégories parentes`);
console.log(` └─ ${childCategories.length} sous-catégories\n`);
// Map slug -> id (pour résoudre les parentId)
const slugToId = new Map<string, string>()
const slugToId = new Map<string, string>();
let created = 0
let updated = 0
let unchanged = 0
let created = 0;
let updated = 0;
let unchanged = 0;
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 1: Créer/MAJ les catégories parentes
// ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50))
console.log("PHASE 1: Catégories parentes")
console.log("═".repeat(50))
console.log("═".repeat(50));
console.log("PHASE 1: Catégories parentes");
console.log("═".repeat(50));
for (const category of parentCategories) {
const result = await upsertCategory(category, null)
slugToId.set(category.slug, result.id)
const result = await upsertCategory(category, null);
slugToId.set(category.slug, result.id);
if (result.action === "created") created++
else if (result.action === "updated") updated++
else unchanged++
if (result.action === "created") created++;
else if (result.action === "updated") updated++;
else unchanged++;
}
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 2: Créer/MAJ les sous-catégories
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50))
console.log("PHASE 2: Sous-catégories")
console.log("═".repeat(50))
console.log("\n" + "═".repeat(50));
console.log("PHASE 2: Sous-catégories");
console.log("═".repeat(50));
for (const category of childCategories) {
const parentId = slugToId.get(category.parentSlug!)
const parentId = slugToId.get(category.parentSlug!);
if (!parentId) {
console.log(`⚠️ Parent introuvable pour: ${category.name} (parentSlug: ${category.parentSlug})`)
continue
console.log(
`⚠️ Parent introuvable pour: ${category.name} (parentSlug: ${category.parentSlug})`,
);
continue;
}
const result = await upsertCategory(category, parentId)
slugToId.set(category.slug, result.id)
const result = await upsertCategory(category, parentId);
slugToId.set(category.slug, result.id);
if (result.action === "created") created++
else if (result.action === "updated") updated++
else unchanged++
if (result.action === "created") created++;
else if (result.action === "updated") updated++;
else unchanged++;
}
// ═══════════════════════════════════════════════════════════════════════════
// RÉSUMÉ
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50))
console.log("📊 RÉSUMÉ CATÉGORIES:")
console.log("═".repeat(50))
console.log(` ✅ Créées: ${created}`)
console.log(` ✏️ Mises à jour: ${updated}`)
console.log(` ⏭️ Inchangées: ${unchanged}`)
console.log("\n" + "═".repeat(50));
console.log("📊 RÉSUMÉ CATÉGORIES:");
console.log("═".repeat(50));
console.log(` ✅ Créées: ${created}`);
console.log(` ✏️ Mises à jour: ${updated}`);
console.log(` ⏭️ Inchangées: ${unchanged}`);
// Stats finales
const totalCategories = await prisma.category.count()
const parentCount = await prisma.category.count({ where: { parentId: null } })
const childCount = await prisma.category.count({ where: { NOT: { parentId: null } } })
const totalKeywords = defaultCategories.reduce((sum, c) => sum + c.keywords.length, 0)
const totalCategories = await prisma.category.count();
const parentCount = await prisma.category.count({
where: { parentId: null },
});
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(` Total catégories: ${totalCategories} (${parentCount} parents, ${childCount} enfants)`)
console.log(` Total keywords: ${totalKeywords}`)
console.log("\n📈 Base de données:");
console.log(
` Total catégories: ${totalCategories} (${parentCount} parents, ${childCount} enfants)`,
);
console.log(` Total keywords: ${totalKeywords}`);
}
// 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(/\s+/g, " ")
.toLowerCase()
.trim()
.trim();
}
async function upsertCategory(
category: CategoryDefinition,
parentId: string | null
parentId: string | null,
): Promise<{ id: string; action: "created" | "updated" | "unchanged" }> {
// Chercher par nom exact d'abord
let existing = await prisma.category.findFirst({
where: { name: category.name },
})
});
// Si pas trouvé, chercher par nom normalisé (sans emoji) dans TOUTES les catégories
if (!existing) {
const allCategories = await prisma.category.findMany()
const normalizedTarget = normalizeName(category.name)
const allCategories = await prisma.category.findMany();
const normalizedTarget = normalizeName(category.name);
for (const cat of allCategories) {
if (normalizeName(cat.name) === normalizedTarget) {
existing = cat
console.log(` 🔗 Match normalisé: "${cat.name}" → "${category.name}"`)
break
existing = cat;
console.log(
` 🔗 Match normalisé: "${cat.name}" → "${category.name}"`,
);
break;
}
}
}
if (existing) {
// 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 =
JSON.stringify(existingKeywords.sort()) !== JSON.stringify([...category.keywords].sort())
const nameChanged = existing.name !== category.name
const colorChanged = existing.color !== category.color
const iconChanged = existing.icon !== category.icon
const parentChanged = existing.parentId !== parentId
JSON.stringify(existingKeywords.sort()) !==
JSON.stringify([...category.keywords].sort());
const nameChanged = existing.name !== category.name;
const colorChanged = existing.color !== category.color;
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({
where: { id: existing.id },
data: {
@@ -186,18 +212,22 @@ async function upsertCategory(
keywords: JSON.stringify(category.keywords),
parentId: parentId,
},
})
console.log(`✏️ MAJ: ${existing.name}${nameChanged ? `${category.name}` : ""}`)
});
console.log(
`✏️ MAJ: ${existing.name}${nameChanged ? `${category.name}` : ""}`,
);
if (keywordsChanged) {
console.log(` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`)
console.log(
` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`,
);
}
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
@@ -209,17 +239,19 @@ async function upsertCategory(
keywords: JSON.stringify(category.keywords),
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()
.catch((e) => {
console.error("❌ Erreur:", e)
process.exit(1)
console.error("❌ Erreur:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect()
})
await prisma.$disconnect();
});

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