diff --git a/package-lock.json b/package-lock.json index 7843c0b..744cf02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2cb23d4..85074f6 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index fae73de..4bc3d46 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -42,7 +42,7 @@ export default async function AccountPage() { return (
- + Hey, {user?.username}! This is your account overview. @@ -81,7 +81,7 @@ export default async function AccountPage() {
- + { process.env.NODE_ENV === 'development' && ( diff --git a/src/app/categories/columns.tsx b/src/app/categories/columns.tsx index 196bcdd..bd8e60d 100644 --- a/src/app/categories/columns.tsx +++ b/src/app/categories/columns.tsx @@ -25,6 +25,7 @@ export const columns = ( ); }, + size: 65, }, { accessorKey: 'createdAt', diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx index d460ade..f7bee73 100644 --- a/src/app/categories/page.tsx +++ b/src/app/categories/page.tsx @@ -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"/> ); } diff --git a/src/app/entities/columns.tsx b/src/app/entities/columns.tsx index a56dc66..90b3754 100644 --- a/src/app/entities/columns.tsx +++ b/src/app/entities/columns.tsx @@ -17,6 +17,7 @@ export const columns = ( { accessorKey: 'type', header: 'Type', + size: 100, }, { accessorKey: 'createdAt', diff --git a/src/app/entities/page.tsx b/src/app/entities/page.tsx index 8c76f29..6f41ba5 100644 --- a/src/app/entities/page.tsx +++ b/src/app/entities/page.tsx @@ -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"/> ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cac7e3c..95bd21f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,10 +22,10 @@ export default function RootLayout({ -
+
{children} -
+ ); diff --git a/src/app/page.tsx b/src/app/page.tsx index b217abf..54450d0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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" /> ); } diff --git a/src/app/payments/columns.tsx b/src/app/payments/columns.tsx index 32a2157..d3b950e 100644 --- a/src/app/payments/columns.tsx +++ b/src/app/payments/columns.tsx @@ -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 = ( ); }, + size: 200, }, { accessorKey: 'note', header: 'Note', + size: 200, }, { id: 'actions', diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx index 38d123c..bbec906 100644 --- a/src/app/payments/page.tsx +++ b/src/app/payments/page.tsx @@ -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"/> ); } diff --git a/src/components/categoryPageClientComponents.tsx b/src/components/categoryPageClientComponents.tsx index ac8b908..f59b3f0 100644 --- a/src/components/categoryPageClientComponents.tsx +++ b/src/components/categoryPageClientComponents.tsx @@ -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

Categories

{/* Edit dialog */} - - - - - - - {selectedCategory?.id ? 'Update Category' : 'Create Category'} - - - - + { + isDesktop ? ( + + + + + + + {selectedCategory?.id ? 'Update Category' : 'Create Category'} + + + + + ) : ( + + + + + + + {selectedCategory?.id ? 'Update Category' : 'Create Category'} + + + + + ) + } {/* Data Table */} diff --git a/src/components/dashboardPageClientComponents.tsx b/src/components/dashboardPageClientComponents.tsx index 314fa5c..8f368da 100644 --- a/src/components/dashboardPageClientComponents.tsx +++ b/src/components/dashboardPageClientComponents.tsx @@ -49,7 +49,7 @@ export default function DashboardPageClientContent( return (
-
+

Dashboard

diff --git a/src/components/entityPageClientComponents.tsx b/src/components/entityPageClientComponents.tsx index 4f4a8bd..3845ee1 100644 --- a/src/components/entityPageClientComponents.tsx +++ b/src/components/entityPageClientComponents.tsx @@ -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

Entities

{/* Edit dialog */} - - - - - - - {selectedEntity?.id ? 'Update Entity' : 'Create Entity'} - - - - + { + isDesktop ? ( + + + + + + + {selectedEntity?.id ? 'Update Entity' : 'Create Entity'} + + + + + ) : ( + + + + + + + {selectedEntity?.id ? 'Update Entity' : 'Create Entity'} + + + + + ) + }
{/* Filter input */} diff --git a/src/components/form/paymentForm.tsx b/src/components/form/paymentForm.tsx index d6323dd..705587e 100644 --- a/src/components/form/paymentForm.tsx +++ b/src/components/form/paymentForm.tsx @@ -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(''); - - const [payorOpen, setPayorOpen] = useState(false); - const [payeeOpen, setPayeeOpen] = useState(false); - const [categoryOpen, setCategoryOpen] = useState(false); - const form = useForm>({ resolver: zodResolver(paymentFormSchema), defaultValues: { @@ -73,6 +67,10 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas }; }) ?? []; + const payeeRef = useRef(null); + const categoryRef = useRef(null); + const noteRef = useRef(null); + return (
@@ -145,61 +143,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas render={({field}) => ( Payor - { - setPayorOpen(open); - setFilter(''); - }}> - - - - - - - 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..."/> - - {entitiesMapped - .filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase())) - .map((item) => ( -
{ - field.onChange(item.value); - setPayorOpen(false); - }}> - - {item.label} -
- ))} - -
-
-
+ + +
)} @@ -211,62 +161,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas render={({field}) => ( Payee - { - setPayeeOpen(open); - setFilter(''); - }}> - - - - - - - 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..."/> - - {entitiesMapped - .filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase())) - .map((item) => ( -
{ - field.onChange(item.value); - setPayeeOpen(false); - }} - > - - {item.label} -
- ))} - -
-
-
+ + +
)} @@ -278,62 +179,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas render={({field}) => ( Category - { - setCategoryOpen(open); - setFilter(''); - }}> - - - - - - - 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..."/> - - {categoriesMapped - .filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase())) - .map((item) => ( -
{ - field.onChange(item.value); - setCategoryOpen(false); - }} - > - - {item.label} -
- ))} - -
-
-
+ + +
)} diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index e6c9648..f004745 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -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 (
- - -
- - Finances - - - - - Dashboard - +
+ setOpen(open)}> + + + + +
+ setOpen(false)} + passHref> + + Dashboard - - - - - Payments - + setOpen(false)} + passHref> + + Payments - - - - - Entities - + setOpen(false)} + passHref> + + Entities - - - - - Categories - + setOpen(false)} + passHref> + + Categories - -
- - - - - Account + setOpen(false)} + passHref> - - - - - + Account + +
+ + +
+
+ + +
+ + Finances + + + + + Dashboard + + + + + + + Payments + + + + + + + Entities + + + + + + + Categories + + + +
+ + + + + Account + + + + +
+
+
); } diff --git a/src/components/paymentPageClientComponents.tsx b/src/components/paymentPageClientComponents.tsx index 22fa031..60a21fd 100644 --- a/src/components/paymentPageClientComponents.tsx +++ b/src/components/paymentPageClientComponents.tsx @@ -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 (

Payments

{/* Edit dialog */} - - - - - - - {selectedPayment?.id ? 'Update Payment' : 'Create Payment'} - - - - + { + isDesktop ? ( + + + + + + + {selectedPayment?.id ? 'Update Payment' : 'Create Payment'} + + + + + ) : ( + + + + + + + {selectedPayment?.id ? 'Update Payment' : 'Create Payment'} + + + + + ) + }
{/* Data Table */} diff --git a/src/components/ui/auto-complete-input.tsx b/src/components/ui/auto-complete-input.tsx new file mode 100644 index 0000000..29363e6 --- /dev/null +++ b/src/components/ui/auto-complete-input.tsx @@ -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 { + items: { label: string, value: any }[]; + next?: React.RefObject; +} + +const AutoCompleteInput = React.forwardRef( + ({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) { + + 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 ( +
+ { + if (e.metaKey || e.ctrlKey || e.altKey) { + props.onKeyDown?.(e); + return; + } + setLastKey(e.key); + props.onKeyDown?.(e); + }} + /> + { + open && ( +
+ {filteredItems?.map((item) => +
{ + props.onChange?.({target: {value: item.value}} as any); + props.next && props.next.current?.focus(); + setValue(item.label); + setOpen(false); + }} + key={item.value}> + {item.label} +
, + )} +
+ ) + } +
+ ); + }, +); +AutoCompleteInput.displayName = 'Input'; + +export { AutoCompleteInput }; diff --git a/src/components/ui/currency-input.tsx b/src/components/ui/currency-input.tsx index 5e41c9e..acc15f3 100644 --- a/src/components/ui/currency-input.tsx +++ b/src/components/ui/currency-input.tsx @@ -53,6 +53,7 @@ export default function CurrencyInput(props: TextInputProps) { { setValue(ev.target.value); diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index c522f86..d672362 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -45,7 +45,8 @@ export function DataTable({ {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender( diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..8f28ade --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -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) => ( + +); +Drawer.displayName = 'Drawer'; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, children, ...props}, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = 'DrawerContent'; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = 'DrawerFooter'; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/src/components/ui/navigation-menu.tsx b/src/components/ui/navigation-menu.tsx index 800788d..71f001a 100644 --- a/src/components/ui/navigation-menu.tsx +++ b/src/components/ui/navigation-menu.tsx @@ -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, React.ComponentPropsWithoutRef @@ -117,6 +121,7 @@ NavigationMenuIndicator.displayName = export { navigationMenuTriggerStyle, + navigationMenuIconTriggerStyle, NavigationMenu, NavigationMenuList, NavigationMenuItem, diff --git a/src/lib/hooks/useMediaQuery.ts b/src/lib/hooks/useMediaQuery.ts new file mode 100644 index 0000000..9b9b3dd --- /dev/null +++ b/src/lib/hooks/useMediaQuery.ts @@ -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; +}