diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/groups/[groupId]/@modal/default.tsx | 3 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/@modal/expense-modal.tsx | 83 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx | 26 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/@modal/expenses/create/page.tsx | 24 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx | 42 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/actions.ts | 28 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/create/page.tsx | 27 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/expense-list.tsx | 9 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/expense-page.tsx | 19 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/layout.tsx | 9 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/page.tsx | 2 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/layout.tsx | 5 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/not-found.tsx | 5 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/reimbursement-list.tsx | 1 | ||||
| -rw-r--r-- | src/components/expense-form.tsx | 388 | ||||
| -rw-r--r-- | src/components/ui/dialog.tsx | 122 |
16 files changed, 527 insertions, 266 deletions
diff --git a/src/app/groups/[groupId]/@modal/default.tsx b/src/app/groups/[groupId]/@modal/default.tsx new file mode 100644 index 0000000..86b9e9a --- /dev/null +++ b/src/app/groups/[groupId]/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/src/app/groups/[groupId]/@modal/expense-modal.tsx b/src/app/groups/[groupId]/@modal/expense-modal.tsx new file mode 100644 index 0000000..17bb302 --- /dev/null +++ b/src/app/groups/[groupId]/@modal/expense-modal.tsx @@ -0,0 +1,83 @@ +'use client' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { useRouter } from 'next/navigation' +import { ReactNode, useEffect, useState } from 'react' +import { Drawer } from 'vaul' + +type Props = { + children: ReactNode + title: ReactNode +} + +export function ExpenseModal(props: Props) { + const size = useTailwindBreakpoint() + if (size === 'xs') { + return <ExpenseVaul {...props} /> + } else { + return <ExpenseDialog {...props} /> + } +} + +export function ExpenseDialog({ children, title }: Props) { + const router = useRouter() + + return ( + <Dialog open onOpenChange={() => router.back()}> + <DialogContent className="w-full max-w-screen-sm"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + </DialogHeader> + {children} + </DialogContent> + </Dialog> + ) +} + +export function ExpenseVaul({ children, title }: Props) { + const router = useRouter() + return ( + <Drawer.Root open onClose={() => router.back()}> + <Drawer.Portal> + <Drawer.Title>{title}</Drawer.Title> + <Drawer.Overlay className="fixed inset-0 bg-background/80 backdrop-blur-sm" /> + <Drawer.Content className="bg-background border flex flex-col rounded-t-[10px] max-h-[90dvh] mt-24 fixed bottom-0 left-0 right-0 z-50"> + <div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-gray-300 dark:bg-gray-700 mt-4"></div> + <div className="text-xl font-bold p-4">{title}</div> + <div className="flex-1 overflow-y-auto p-4 pt-0">{children}</div> + </Drawer.Content> + </Drawer.Portal> + </Drawer.Root> + ) +} + +export function useTailwindBreakpoint() { + const [size, setSize] = useState<'xs' | 'sm' | 'md' | 'lg'>('xs') + + useEffect(() => { + const handleBreakpointChange = () => { + if (window.innerWidth >= 1200) { + setSize('lg') + } else if (window.innerWidth >= 768) { + setSize('md') + } else if (window.innerWidth >= 640) { + setSize('sm') + } else { + setSize('xs') + } + } + + window.addEventListener('resize', handleBreakpointChange) + handleBreakpointChange() + + return () => { + window.removeEventListener('resize', handleBreakpointChange) + } + }, []) + + return size +} diff --git a/src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx new file mode 100644 index 0000000..fb70f45 --- /dev/null +++ b/src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx @@ -0,0 +1,26 @@ +import { ExpenseModal } from '@/app/groups/[groupId]/@modal/expense-modal' +import { ExpenseForm } from '@/components/expense-form' +import { getExpense, getGroup } from '@/lib/api' +import { Metadata } from 'next' +import { notFound } from 'next/navigation' + +export const metadata: Metadata = { + title: 'Edit expense', +} + +export default async function EditExpensePage({ + params: { groupId, expenseId }, +}: { + params: { groupId: string; expenseId: string } +}) { + const group = await getGroup(groupId) + if (!group) notFound() + const expense = await getExpense(groupId, expenseId) + if (!expense) notFound() + + return ( + <ExpenseModal title="Edit expense"> + <ExpenseForm group={group} expense={expense} /> + </ExpenseModal> + ) +} diff --git a/src/app/groups/[groupId]/@modal/expenses/create/page.tsx b/src/app/groups/[groupId]/@modal/expenses/create/page.tsx new file mode 100644 index 0000000..93cc413 --- /dev/null +++ b/src/app/groups/[groupId]/@modal/expenses/create/page.tsx @@ -0,0 +1,24 @@ +import { ExpenseModal } from '@/app/groups/[groupId]/@modal/expense-modal' +import { ExpenseForm } from '@/components/expense-form' +import { getGroup } from '@/lib/api' +import { Metadata } from 'next' +import { notFound } from 'next/navigation' + +export const metadata: Metadata = { + title: 'Create expense', +} + +export default async function ExpensePage({ + params: { groupId }, +}: { + params: { groupId: string } +}) { + const group = await getGroup(groupId) + if (!group) notFound() + + return ( + <ExpenseModal title="Create expense"> + <ExpenseForm group={group} /> + </ExpenseModal> + ) +} diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx deleted file mode 100644 index 188d08f..0000000 --- a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { ExpenseForm } from '@/components/expense-form' -import { deleteExpense, getExpense, getGroup, updateExpense } from '@/lib/api' -import { expenseFormSchema } from '@/lib/schemas' -import { Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' - -export const metadata: Metadata = { - title: 'Edit expense', -} - -export default async function EditExpensePage({ - params: { groupId, expenseId }, -}: { - params: { groupId: string; expenseId: string } -}) { - const group = await getGroup(groupId) - if (!group) notFound() - const expense = await getExpense(groupId, expenseId) - if (!expense) notFound() - - async function updateExpenseAction(values: unknown) { - 'use server' - const expenseFormValues = expenseFormSchema.parse(values) - await updateExpense(groupId, expenseId, expenseFormValues) - redirect(`/groups/${groupId}`) - } - - async function deleteExpenseAction() { - 'use server' - await deleteExpense(expenseId) - redirect(`/groups/${groupId}`) - } - - return ( - <ExpenseForm - group={group} - expense={expense} - onSubmit={updateExpenseAction} - onDelete={deleteExpenseAction} - /> - ) -} diff --git a/src/app/groups/[groupId]/expenses/actions.ts b/src/app/groups/[groupId]/expenses/actions.ts new file mode 100644 index 0000000..31bb2e7 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/actions.ts @@ -0,0 +1,28 @@ +'use server' +import { createExpense, deleteExpense, updateExpense } from '@/lib/api' +import { expenseFormSchema } from '@/lib/schemas' +import { revalidatePath } from 'next/cache' + +export async function createExpenseAction(groupId: string, values: unknown) { + 'use server' + const expenseFormValues = expenseFormSchema.parse(values) + await createExpense(expenseFormValues, groupId) + revalidatePath(`/groups/${groupId}`, 'layout') +} + +export async function updateExpenseAction( + groupId: string, + expenseId: string, + values: unknown, +) { + 'use server' + const expenseFormValues = expenseFormSchema.parse(values) + await updateExpense(groupId, expenseId, expenseFormValues) + revalidatePath(`/groups/${groupId}`, 'layout') +} + +export async function deleteExpenseAction(groupId: string, expenseId: string) { + 'use server' + await deleteExpense(expenseId) + revalidatePath(`/groups/${groupId}`, 'layout') +} diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx deleted file mode 100644 index e603e59..0000000 --- a/src/app/groups/[groupId]/expenses/create/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ExpenseForm } from '@/components/expense-form' -import { createExpense, getGroup } from '@/lib/api' -import { expenseFormSchema } from '@/lib/schemas' -import { Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' - -export const metadata: Metadata = { - title: 'Create expense', -} - -export default async function ExpensePage({ - params: { groupId }, -}: { - params: { groupId: string } -}) { - const group = await getGroup(groupId) - if (!group) notFound() - - async function createExpenseAction(values: unknown) { - 'use server' - const expenseFormValues = expenseFormSchema.parse(values) - await createExpense(expenseFormValues, groupId) - redirect(`/groups/${groupId}`) - } - - return <ExpenseForm group={group} onSubmit={createExpenseAction} /> -} diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index 798a5f8..adc6e7a 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -33,7 +33,9 @@ export function ExpenseList({ expense.isReimbursement && 'italic', )} onClick={() => { - router.push(`/groups/${groupId}/expenses/${expense.id}/edit`) + router.push(`/groups/${groupId}/expenses/${expense.id}/edit`, { + scroll: false, + }) }} > <div> @@ -66,7 +68,10 @@ export function ExpenseList({ {currency} {(expense.amount / 100).toFixed(2)} </div> <Button size="icon" variant="link" className="-my-2" asChild> - <Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}> + <Link + href={`/groups/${groupId}/expenses/${expense.id}/edit`} + scroll={false} + > <ChevronRight className="w-4 h-4" /> </Link> </Button> diff --git a/src/app/groups/[groupId]/expenses/expense-page.tsx b/src/app/groups/[groupId]/expenses/expense-page.tsx new file mode 100644 index 0000000..281c38b --- /dev/null +++ b/src/app/groups/[groupId]/expenses/expense-page.tsx @@ -0,0 +1,19 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { ReactNode } from 'react' + +export function ExpensePage({ + children, + title, +}: { + children: ReactNode + title: ReactNode +}) { + return ( + <Card> + <CardHeader> + <CardTitle>{title}</CardTitle> + </CardHeader> + <CardContent>{children}</CardContent> + </Card> + ) +} diff --git a/src/app/groups/[groupId]/expenses/layout.tsx b/src/app/groups/[groupId]/expenses/layout.tsx new file mode 100644 index 0000000..cd65df9 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function GroupExpensesLayout({ + children, +}: { + children: ReactNode +}) { + return <>{children}</> +} diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx index 5285cb8..64987a3 100644 --- a/src/app/groups/[groupId]/expenses/page.tsx +++ b/src/app/groups/[groupId]/expenses/page.tsx @@ -35,7 +35,7 @@ export default async function GroupExpensesPage({ </CardHeader> <CardHeader> <Button asChild size="icon"> - <Link href={`/groups/${groupId}/expenses/create`}> + <Link href={`/groups/${groupId}/expenses/create`} scroll={false}> <Plus /> </Link> </Button> diff --git a/src/app/groups/[groupId]/layout.tsx b/src/app/groups/[groupId]/layout.tsx index 3e649e7..40f6155 100644 --- a/src/app/groups/[groupId]/layout.tsx +++ b/src/app/groups/[groupId]/layout.tsx @@ -5,12 +5,13 @@ import { getGroup } from '@/lib/api' import { Metadata } from 'next' import Link from 'next/link' import { notFound } from 'next/navigation' -import { PropsWithChildren } from 'react' +import { PropsWithChildren, ReactNode } from 'react' type Props = { params: { groupId: string } + modal: ReactNode } export async function generateMetadata({ @@ -28,6 +29,7 @@ export async function generateMetadata({ export default async function GroupLayout({ children, + modal, params: { groupId }, }: PropsWithChildren<Props>) { const group = await getGroup(groupId) @@ -47,6 +49,7 @@ export default async function GroupLayout({ </div> {children} + {modal} <SaveGroupLocally group={{ id: group.id, name: group.name }} /> </> diff --git a/src/app/groups/[groupId]/not-found.tsx b/src/app/groups/[groupId]/not-found.tsx new file mode 100644 index 0000000..af3788b --- /dev/null +++ b/src/app/groups/[groupId]/not-found.tsx @@ -0,0 +1,5 @@ +'use client' + +export default function NotFound() { + return null +} diff --git a/src/app/groups/[groupId]/reimbursement-list.tsx b/src/app/groups/[groupId]/reimbursement-list.tsx index 54ee9d7..1153b59 100644 --- a/src/app/groups/[groupId]/reimbursement-list.tsx +++ b/src/app/groups/[groupId]/reimbursement-list.tsx @@ -37,6 +37,7 @@ export function ReimbursementList({ <Button variant="link" asChild className="-mx-4 -my-3"> <Link href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`} + scroll={false} > Mark as paid </Link> diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index bdee9d0..0f677e6 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -1,14 +1,12 @@ 'use client' +import { + createExpenseAction, + deleteExpenseAction, + updateExpenseAction, +} from '@/app/groups/[groupId]/expenses/actions' import { AsyncButton } from '@/components/async-button' import { SubmitButton } from '@/components/submit-button' import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Form, @@ -30,17 +28,15 @@ import { import { getExpense, getGroup } from '@/lib/api' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { zodResolver } from '@hookform/resolvers/zod' -import { useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { useForm } from 'react-hook-form' export type Props = { group: NonNullable<Awaited<ReturnType<typeof getGroup>>> expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>> - onSubmit: (values: ExpenseFormValues) => Promise<void> - onDelete?: () => Promise<void> } -export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { +export function ExpenseForm({ group, expense }: Props) { const isCreate = expense === undefined const searchParams = useSearchParams() const form = useForm<ExpenseFormValues>({ @@ -65,204 +61,210 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { } : { title: '', amount: 0, paidFor: [], isReimbursement: false }, }) + const router = useRouter() return ( <Form {...form}> - <form onSubmit={form.handleSubmit((values) => onSubmit(values))}> - <Card> - <CardHeader> - <CardTitle> - {isCreate ? <>Create expense</> : <>Edit expense</>} - </CardTitle> - </CardHeader> - <CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6"> - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem className="order-1"> - <FormLabel>Expense title</FormLabel> + <form + onSubmit={form.handleSubmit(async (values) => { + if (expense) { + await updateExpenseAction(group.id, expense.id, values) + } else { + await createExpenseAction(group.id, values) + } + router.back() + })} + > + <div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem className="order-1"> + <FormLabel>Expense title</FormLabel> + <FormControl> + <Input + placeholder="Monday evening restaurant" + className="text-base" + {...field} + /> + </FormControl> + <FormDescription> + Enter a description for the expense. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="paidBy" + render={({ field }) => ( + <FormItem className="order-3 sm:order-2"> + <FormLabel>Paid by</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select a participant" /> + </SelectTrigger> + <SelectContent> + {group.participants.map(({ id, name }) => ( + <SelectItem key={id} value={id}> + {name} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + Select the participant who paid the expense. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="amount" + render={({ field }) => ( + <FormItem className="order-2 sm:order-3"> + <FormLabel>Amount</FormLabel> + <div className="flex items-baseline gap-2"> + <span>{group.currency}</span> <FormControl> <Input - placeholder="Monday evening restaurant" - className="text-base" + className="text-base max-w-[120px]" + type="number" + inputMode="decimal" + step={0.01} + placeholder="0.00" {...field} /> </FormControl> - <FormDescription> - Enter a description for the expense. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> + </div> + <FormMessage /> - <FormField - control={form.control} - name="paidBy" - render={({ field }) => ( - <FormItem className="order-3 sm:order-2"> - <FormLabel>Paid by</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <SelectTrigger> - <SelectValue placeholder="Select a participant" /> - </SelectTrigger> - <SelectContent> - {group.participants.map(({ id, name }) => ( - <SelectItem key={id} value={id}> - {name} - </SelectItem> - ))} - </SelectContent> - </Select> + <FormField + control={form.control} + name="isReimbursement" + render={({ field }) => ( + <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div> + <FormLabel>This is a reimbursement</FormLabel> + </div> + </FormItem> + )} + /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="paidFor" + render={() => ( + <FormItem className="order-5"> + <div className="mb-4"> + <FormLabel> + Paid for + <Button + variant="link" + type="button" + className="-m-2" + onClick={() => { + const paidFor = form.getValues().paidFor + const allSelected = + paidFor.length === group.participants.length + const newPairFor = allSelected + ? [] + : group.participants.map((p) => p.id) + form.setValue('paidFor', newPairFor, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + }} + > + {form.getValues().paidFor.length === + group.participants.length ? ( + <>Select none</> + ) : ( + <>Select all</> + )} + </Button> + </FormLabel> <FormDescription> - Select the participant who paid the expense. + Select who the expense was paid for. </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="amount" - render={({ field }) => ( - <FormItem className="order-2 sm:order-3"> - <FormLabel>Amount</FormLabel> - <div className="flex items-baseline gap-2"> - <span>{group.currency}</span> - <FormControl> - <Input - className="text-base max-w-[120px]" - type="number" - inputMode="decimal" - step={0.01} - placeholder="0.00" - {...field} - /> - </FormControl> - </div> - <FormMessage /> - + </div> + {group.participants.map(({ id, name }) => ( <FormField + key={id} control={form.control} - name="isReimbursement" - render={({ field }) => ( - <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div> - <FormLabel>This is a reimbursement</FormLabel> - </div> - </FormItem> - )} + name="paidFor" + render={({ field }) => { + return ( + <FormItem + key={id} + className="flex flex-row items-start space-x-3 space-y-0" + > + <FormControl> + <Checkbox + checked={field.value?.includes(id)} + onCheckedChange={(checked) => { + return checked + ? field.onChange([...field.value, id]) + : field.onChange( + field.value?.filter( + (value) => value !== id, + ), + ) + }} + /> + </FormControl> + <FormLabel className="text-sm font-normal"> + {name} + </FormLabel> + </FormItem> + ) + }} /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="paidFor" - render={() => ( - <FormItem className="order-5"> - <div className="mb-4"> - <FormLabel> - Paid for - <Button - variant="link" - type="button" - className="-m-2" - onClick={() => { - const paidFor = form.getValues().paidFor - const allSelected = - paidFor.length === group.participants.length - const newPairFor = allSelected - ? [] - : group.participants.map((p) => p.id) - form.setValue('paidFor', newPairFor, { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }) - }} - > - {form.getValues().paidFor.length === - group.participants.length ? ( - <>Select none</> - ) : ( - <>Select all</> - )} - </Button> - </FormLabel> - <FormDescription> - Select who the expense was paid for. - </FormDescription> - </div> - {group.participants.map(({ id, name }) => ( - <FormField - key={id} - control={form.control} - name="paidFor" - render={({ field }) => { - return ( - <FormItem - key={id} - className="flex flex-row items-start space-x-3 space-y-0" - > - <FormControl> - <Checkbox - checked={field.value?.includes(id)} - onCheckedChange={(checked) => { - return checked - ? field.onChange([...field.value, id]) - : field.onChange( - field.value?.filter( - (value) => value !== id, - ), - ) - }} - /> - </FormControl> - <FormLabel className="text-sm font-normal"> - {name} - </FormLabel> - </FormItem> - ) - }} - /> - ))} - <FormMessage /> - </FormItem> - )} - /> - </CardContent> + ))} + <FormMessage /> + </FormItem> + )} + /> + </div> - <CardFooter className="gap-2"> - <SubmitButton - loadingContent={isCreate ? <>Creating…</> : <>Saving…</>} + <div className="mt-6 flex gap-2"> + <SubmitButton + loadingContent={isCreate ? <>Creating…</> : <>Saving…</>} + > + {isCreate ? <>Create</> : <>Save</>} + </SubmitButton> + {!isCreate && ( + <AsyncButton + type="button" + variant="destructive" + loadingContent="Deleting…" + action={async () => { + await deleteExpenseAction(group.id, expense.id) + router.back() + }} > - {isCreate ? <>Create</> : <>Save</>} - </SubmitButton> - {!isCreate && onDelete && ( - <AsyncButton - type="button" - variant="destructive" - loadingContent="Deleting…" - action={onDelete} - > - Delete - </AsyncButton> - )} - </CardFooter> - </Card> + Delete + </AsyncButton> + )} + </div> </form> </Form> ) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..cad6f58 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + /> +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + > + {children} + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-1.5 text-center sm:text-left", + className + )} + {...props} + /> +) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} |
