Release v1.1.0 (#40)
This commit is contained in:
commit
b34f5e7f3f
23 changed files with 560 additions and 308 deletions
17
package-lock.json
generated
17
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "next-finances",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "next-finances",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
|
@ -40,6 +40,7 @@
|
|||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -6184,6 +6185,18 @@
|
|||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.0.tgz",
|
||||
"integrity": "sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"name": "next-finances",
|
||||
"description": "A finances application to keep track of my personal spendings",
|
||||
"homepage": "https://github.com/MarkusThielker/next-finances",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Markus Thielker"
|
||||
|
@ -50,6 +50,7 @@
|
|||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -42,7 +42,7 @@ export default async function AccountPage() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<Card className="w-full max-w-md mt-12">
|
||||
<Card className="w-full max-w-md md:mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle>Hey, {user?.username}!</CardTitle>
|
||||
<CardDescription>This is your account overview.</CardDescription>
|
||||
|
@ -81,7 +81,7 @@ export default async function AccountPage() {
|
|||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="space-x-4">
|
||||
<CardFooter className="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
{
|
||||
process.env.NODE_ENV === 'development' && (
|
||||
<GenerateSampleDataForm onSubmit={generateSampleData}/>
|
||||
|
|
|
@ -25,6 +25,7 @@ export const columns = (
|
|||
</svg>
|
||||
);
|
||||
},
|
||||
size: 65,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
|
|
|
@ -25,6 +25,6 @@ export default async function CategoriesPage() {
|
|||
categories={categories}
|
||||
onSubmit={categoryCreateUpdate}
|
||||
onDelete={categoryDelete}
|
||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
||||
className="flex flex-col justify-center space-y-4"/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export const columns = (
|
|||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
|
|
|
@ -28,6 +28,6 @@ export default async function EntitiesPage() {
|
|||
entities={entities}
|
||||
onSubmit={entityCreateUpdate}
|
||||
onDelete={entityDelete}
|
||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
||||
className="flex flex-col justify-center space-y-4"/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,10 +22,10 @@ export default function RootLayout({
|
|||
<html lang="en">
|
||||
<body className={cn('dark', inter.className)}>
|
||||
<Navigation/>
|
||||
<main>
|
||||
<main className="p-4 sm:p-8">
|
||||
{children}
|
||||
<Toaster/>
|
||||
</main>
|
||||
<Toaster/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
@ -200,7 +200,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
|
|||
categoryPercentages={categoryPercentages}
|
||||
entityExpenses={entityExpensesFormat}
|
||||
entityPercentages={entityPercentages}
|
||||
className="flex flex-col justify-center space-y-4 p-10"
|
||||
className="flex flex-col justify-center space-y-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ export const columns = (
|
|||
cell: ({row}) => {
|
||||
return format(row.original.date, 'PPP');
|
||||
},
|
||||
size: 175,
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
|
@ -28,6 +29,7 @@ export const columns = (
|
|||
currency: 'EUR',
|
||||
}).format(row.getValue('amount') as number / 100);
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
accessorKey: 'payorId',
|
||||
|
@ -36,6 +38,7 @@ export const columns = (
|
|||
const entity = entities.find((entity) => entity.id === row.original.payorId);
|
||||
return entity?.name ?? '-';
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'payeeId',
|
||||
|
@ -44,6 +47,7 @@ export const columns = (
|
|||
const entity = entities.find((entity) => entity.id === row.original.payeeId);
|
||||
return entity?.name ?? '-';
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'categoryId',
|
||||
|
@ -60,10 +64,12 @@ export const columns = (
|
|||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'note',
|
||||
header: 'Note',
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
|
|
@ -58,6 +58,6 @@ export default async function PaymentsPage() {
|
|||
categories={categories}
|
||||
onSubmit={paymentCreateUpdate}
|
||||
onDelete={paymentDelete}
|
||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
||||
className="flex flex-col justify-center space-y-4"/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||
import CategoryForm from '@/components/form/categoryForm';
|
||||
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
|
||||
|
||||
export default function CategoryPageClientContent({categories, onSubmit, onDelete, className}: {
|
||||
categories: Category[],
|
||||
|
@ -31,6 +33,7 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
|
|||
className: string,
|
||||
}) {
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
@ -97,26 +100,51 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
|
|||
<p className="text-3xl font-semibold">Categories</p>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedCategory(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Category
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CategoryForm
|
||||
value={selectedCategory}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-row space-x-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{
|
||||
isDesktop ? (
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedCategory(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Category
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CategoryForm
|
||||
value={selectedCategory}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-row space-x-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedCategory(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Category
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-4">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<CategoryForm
|
||||
value={selectedCategory}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-row space-x-4 py-4"/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
|
|
|
@ -49,7 +49,7 @@ export default function DashboardPageClientContent(
|
|||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center justify-between w-full space-x-8">
|
||||
|
||||
<p className="text-3xl font-semibold">Dashboard</p>
|
||||
|
||||
|
|
|
@ -24,6 +24,8 @@ import {
|
|||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
|
||||
|
||||
export default function EntityPageClientContent({entities, onSubmit, onDelete, className}: {
|
||||
entities: Entity[],
|
||||
|
@ -32,6 +34,7 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
|
|||
className: string,
|
||||
}) {
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
@ -125,26 +128,51 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
|
|||
<p className="text-3xl font-semibold">Entities</p>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEntity(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Entity
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EntityForm
|
||||
value={selectedEntity}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{
|
||||
isDesktop ? (
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEntity(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Entity
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EntityForm
|
||||
value={selectedEntity}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEntity(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Entity
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-4">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<EntityForm
|
||||
value={selectedEntity}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Filter input */}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
|
|||
import { z } from 'zod';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
@ -16,11 +16,11 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
|||
import CurrencyInput from '@/components/ui/currency-input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CalendarIcon, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
|
||||
|
||||
export default function PaymentForm({value, entities, categories, onSubmit, className}: {
|
||||
value: Payment | undefined,
|
||||
|
@ -32,12 +32,6 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
const [payorOpen, setPayorOpen] = useState(false);
|
||||
const [payeeOpen, setPayeeOpen] = useState(false);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof paymentFormSchema>>({
|
||||
resolver: zodResolver(paymentFormSchema),
|
||||
defaultValues: {
|
||||
|
@ -73,6 +67,10 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
};
|
||||
}) ?? [];
|
||||
|
||||
const payeeRef = useRef<HTMLInputElement>(null);
|
||||
const categoryRef = useRef<HTMLInputElement>(null);
|
||||
const noteRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
|
@ -145,61 +143,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payor</FormLabel>
|
||||
<Popover open={payorOpen} onOpenChange={(open) => {
|
||||
setPayorOpen(open);
|
||||
setFilter('');
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!field.value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? entitiesMapped.find(
|
||||
(item) => item.value === field.value,
|
||||
)?.label
|
||||
: 'Select entity'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[225px] p-0">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Search..."/>
|
||||
<ScrollArea className="h-64">
|
||||
{entitiesMapped
|
||||
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
|
||||
.map((item) => (
|
||||
<div
|
||||
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
field.onChange(item.value);
|
||||
setPayorOpen(false);
|
||||
}}>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
item.value === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
<ScrollBar orientation="vertical"/>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormControl>
|
||||
<AutoCompleteInput
|
||||
placeholder="Select payor"
|
||||
items={entitiesMapped}
|
||||
next={payeeRef}
|
||||
{...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -211,62 +161,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payee</FormLabel>
|
||||
<Popover open={payeeOpen} onOpenChange={(open) => {
|
||||
setPayeeOpen(open);
|
||||
setFilter('');
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!field.value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? entitiesMapped.find(
|
||||
(item) => item.value === field.value,
|
||||
)?.label
|
||||
: 'Select entity'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[225px] p-0">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Search..."/>
|
||||
<ScrollArea className="h-40">
|
||||
{entitiesMapped
|
||||
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
|
||||
.map((item) => (
|
||||
<div
|
||||
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
field.onChange(item.value);
|
||||
setPayeeOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
item.value === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
<ScrollBar orientation="vertical"/>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormControl ref={payeeRef}>
|
||||
<AutoCompleteInput
|
||||
placeholder="Select payee"
|
||||
items={entitiesMapped}
|
||||
next={categoryRef}
|
||||
{...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -278,62 +179,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Popover open={categoryOpen} onOpenChange={(open) => {
|
||||
setCategoryOpen(open);
|
||||
setFilter('');
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!field.value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? categoriesMapped.find(
|
||||
(item) => item.value === field.value,
|
||||
)?.label
|
||||
: 'Select entity'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[225px] p-0">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Search..."/>
|
||||
<ScrollArea className="h-40">
|
||||
{categoriesMapped
|
||||
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
|
||||
.map((item) => (
|
||||
<div
|
||||
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
field.onChange(item.value);
|
||||
setCategoryOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
item.value === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
<ScrollBar orientation="vertical"/>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormControl ref={categoryRef}>
|
||||
<AutoCompleteInput
|
||||
placeholder="Select category"
|
||||
items={categoriesMapped}
|
||||
next={noteRef}
|
||||
{...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
|
@ -2,65 +2,125 @@
|
|||
|
||||
import {
|
||||
NavigationMenu,
|
||||
navigationMenuIconTriggerStyle,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { User } from 'lucide-react';
|
||||
import { Banknote, Home, Menu, Tag, User, UserSearch } from 'lucide-react';
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function Navigation() {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex sticky items-center border-b border-border bg-background">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className="flex w-screen items-center justify-between px-4 py-2">
|
||||
<div className="inline-flex space-x-2">
|
||||
|
||||
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link href="/" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Dashboard
|
||||
</NavigationMenuLink>
|
||||
<div className="md:hidden">
|
||||
<Drawer open={open} onOpenChange={open => setOpen(open)}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="m-2">
|
||||
<Menu/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="flex flex-col space-y-2 w-full rounded-none p-4">
|
||||
<Link
|
||||
href="/"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<Home/>
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/payments" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Payments
|
||||
</NavigationMenuLink>
|
||||
<Link
|
||||
href="/payments"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<Banknote/>
|
||||
<span>Payments</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/entities" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Entities
|
||||
</NavigationMenuLink>
|
||||
<Link
|
||||
href="/entities"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<UserSearch/>
|
||||
<span>Entities</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/categories" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Categories
|
||||
</NavigationMenuLink>
|
||||
<Link
|
||||
href="/categories"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<Tag/>
|
||||
<span>Categories</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</div>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link href="/account" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
<span className="sr-only">Account</span>
|
||||
<Link
|
||||
href="/account"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<User/>
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<span>Account</span>
|
||||
</Link>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
<div className="hidden md:flex">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className="flex w-screen items-center justify-between sm:px-4 py-2">
|
||||
<div className="inline-flex space-x-2">
|
||||
|
||||
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link href="/" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Dashboard
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/payments" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Payments
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/entities" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Entities
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/categories" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Categories
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</div>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link href="/account" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
<span className="sr-only">Account</span>
|
||||
<User/>
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
|||
import { Category, Entity, Payment } from '@prisma/client';
|
||||
import PaymentForm from '@/components/form/paymentForm';
|
||||
import { columns } from '@/app/payments/columns';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
|
||||
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
|
||||
|
||||
export default function PaymentPageClientContent({
|
||||
payments,
|
||||
|
@ -40,6 +42,7 @@ export default function PaymentPageClientContent({
|
|||
className: string,
|
||||
}) {
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
@ -100,48 +103,61 @@ export default function PaymentPageClientContent({
|
|||
);
|
||||
};
|
||||
|
||||
const entitiesMapped = entities?.map((entity) => {
|
||||
return {
|
||||
label: entity.name,
|
||||
value: entity.id,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
const categoriesMapped = categories?.map((category) => {
|
||||
return {
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p className="text-3xl font-semibold">Payments</p>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedPayment(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Payment
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PaymentForm
|
||||
value={selectedPayment}
|
||||
entities={entities}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{
|
||||
isDesktop ? (
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedPayment(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Payment
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PaymentForm
|
||||
value={selectedPayment}
|
||||
entities={entities}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedPayment(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Payment
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-4">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<PaymentForm
|
||||
value={selectedPayment}
|
||||
entities={entities}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
|
|
100
src/components/ui/auto-complete-input.tsx
Normal file
100
src/components/ui/auto-complete-input.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface AutoCompleteInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
items: { label: string, value: any }[];
|
||||
next?: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputProps>(
|
||||
({className, type, ...props}, ref) => {
|
||||
|
||||
const [value, setValue] = useState(getInitialValue());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lastKey, setLastKey] = useState('');
|
||||
const [filteredItems, setFilteredItems] = useState(props.items);
|
||||
|
||||
function getInitialValue() {
|
||||
|
||||
if (!props.items) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const item = props.items?.find(item => item.value === props.value);
|
||||
return item?.label || '';
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
|
||||
props.onChange?.(undefined as any);
|
||||
const value = e.target.value;
|
||||
|
||||
setFilteredItems(props?.items?.filter((item) => {
|
||||
return item.label.toLowerCase().includes(value.toLowerCase());
|
||||
}));
|
||||
|
||||
setValue(value);
|
||||
setOpen(value.length > 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredItems.length === 1 && /^[a-zA-Z0-9]$/.test(lastKey)) {
|
||||
setValue(filteredItems[0].label);
|
||||
setOpen(false);
|
||||
props.onChange?.({target: {value: filteredItems[0].value}} as any);
|
||||
props.next && props.next.current?.focus();
|
||||
}
|
||||
}, [filteredItems]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
value={value}
|
||||
placeholder={props.placeholder || 'Search...'}
|
||||
onChange={handleChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) {
|
||||
props.onKeyDown?.(e);
|
||||
return;
|
||||
}
|
||||
setLastKey(e.key);
|
||||
props.onKeyDown?.(e);
|
||||
}}
|
||||
/>
|
||||
{
|
||||
open && (
|
||||
<div
|
||||
className="z-50 bg-background rounded-md border border-border absolute inset-x-0 top-12 max-h-44 overflow-scroll">
|
||||
{filteredItems?.map((item) =>
|
||||
<div
|
||||
className="px-3 py-3 hover:bg-accent hover:text-accent-foreground cursor-pointer text-sm font-medium"
|
||||
onClick={() => {
|
||||
props.onChange?.({target: {value: item.value}} as any);
|
||||
props.next && props.next.current?.focus();
|
||||
setValue(item.label);
|
||||
setOpen(false);
|
||||
}}
|
||||
key={item.value}>
|
||||
{item.label}
|
||||
</div>,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
AutoCompleteInput.displayName = 'Input';
|
||||
|
||||
export { AutoCompleteInput };
|
|
@ -53,6 +53,7 @@ export default function CurrencyInput(props: TextInputProps) {
|
|||
<Input
|
||||
placeholder={props.placeholder}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(ev) => {
|
||||
setValue(ev.target.value);
|
||||
|
|
|
@ -45,7 +45,8 @@ export function DataTable<TData, TValue>({
|
|||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
<TableHead key={header.id}
|
||||
style={{minWidth: `${header.column.getSize()}px`}}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
|
|
118
src/components/ui/drawer.tsx
Normal file
118
src/components/ui/drawer.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = 'Drawer';
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({className, ...props}, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/80', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({className, children, ...props}, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay/>
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted"/>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = 'DrawerContent';
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = 'DrawerHeader';
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = 'DrawerFooter';
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({className, ...props}, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({className, ...props}, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
|
@ -44,6 +44,10 @@ const navigationMenuTriggerStyle = cva(
|
|||
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
|
||||
);
|
||||
|
||||
const navigationMenuIconTriggerStyle = cva(
|
||||
'group inline-flex h-10 w-full items-center justify-start rounded-md bg-background px-4 py-2 space-x-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
|
@ -117,6 +121,7 @@ NavigationMenuIndicator.displayName =
|
|||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
navigationMenuIconTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
|
|
21
src/lib/hooks/useMediaQuery.ts
Normal file
21
src/lib/hooks/useMediaQuery.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useMediaQuery(mq: string) {
|
||||
|
||||
const [matches, setMatch] = useState(
|
||||
() => typeof window !== 'undefined' ? window.matchMedia(mq).matches : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const mql = window.matchMedia(mq);
|
||||
const listener = (e: any) => setMatch(e.matches);
|
||||
mql.addEventListener('change', listener);
|
||||
return () => mql.removeEventListener('change', listener);
|
||||
}
|
||||
}, [mq]);
|
||||
|
||||
return matches;
|
||||
}
|
Loading…
Add table
Reference in a new issue