awesome ui changes

This commit is contained in:
shrt 2025-04-07 19:58:58 +02:00
parent 0cec39717e
commit 2ddbef6627
52 changed files with 1965 additions and 484 deletions

View File

@ -26,6 +26,8 @@
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@t3-oss/env-nextjs": "^0.12.0",

112
pnpm-lock.yaml generated
View File

@ -32,6 +32,12 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-select':
specifier: ^2.1.6
version: 2.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-separator':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot':
specifier: ^1.1.2
version: 1.1.2(@types/react@19.1.0)(react@19.1.0)
@ -705,6 +711,9 @@ packages:
'@petamoriken/float16@3.9.2':
resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
'@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
@ -948,6 +957,32 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.1.6':
resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.2':
resolution: {integrity: sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.1.2':
resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==}
peerDependencies:
@ -1006,6 +1041,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.0':
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies:
@ -1024,6 +1068,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.1.2':
resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
@ -2085,6 +2142,8 @@ snapshots:
'@petamoriken/float16@3.9.2': {}
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {}
'@radix-ui/react-arrow@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
@ -2334,6 +2393,44 @@ snapshots:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/react-select@2.1.6(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.0
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-collection': 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-direction': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-popper': 1.2.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.4(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.1.2(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.0(@types/react@19.1.0)(react@19.1.0)
'@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
aria-hidden: 1.2.4
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-remove-scroll: 2.6.3(@types/react@19.1.0)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/react-separator@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/react-slot@1.1.2(@types/react@19.1.0)(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.0)(react@19.1.0)
@ -2383,6 +2480,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.0
'@radix-ui/react-use-previous@1.1.0(@types/react@19.1.0)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.0
'@radix-ui/react-use-rect@1.1.0(@types/react@19.1.0)(react@19.1.0)':
dependencies:
'@radix-ui/rect': 1.1.0
@ -2397,6 +2500,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.0
'@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
'@radix-ui/rect@1.1.0': {}
'@standard-schema/utils@0.3.0': {}

View File

@ -9,27 +9,27 @@ export const appConfig: AppConfig = {
{
name: "Home",
href: "/",
icon: "Home",
icon: "home",
},
{
name: "Expenses",
href: "/expense",
icon: "Wallet",
icon: "wallet",
},
{
name: "Add Expense",
name: "Add",
href: "/add",
icon: "Plus",
icon: "addSquare",
},
{
name: "Groups",
href: "/group",
icon: "Users",
icon: "group",
},
{
name: "Friends",
href: "/friend",
icon: "User",
icon: "friends",
},
],
};

View File

@ -1,6 +1,7 @@
import ExpenseForm from "@/app/_components/expense/expense-form";
import Header from "@/components/header";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import { auth } from "@/server/auth";
import { api, HydrateClient } from "@/trpc/server";
import React from "react";
@ -10,9 +11,13 @@ export default async function Page() {
if (session?.user) void api.friend.getAll.prefetch();
return (
<HydrateClient>
<Header text="Add Expense"></Header>
<Header text="Add Expense">
<Button type="submit" form="expense-form">
Create Expense
</Button>
</Header>
<Section>
<ExpenseForm />
<ExpenseForm hideSubmit session={session!} />
</Section>
</HydrateClient>
);

View File

@ -1,5 +1,16 @@
import Header from "@/components/header";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import React from "react";
export default function Page() {
return <div>Page</div>;
return (
<>
<Header text="Expenses">
<Button asChild>
<Link href="/add">Add Expense</Link>
</Button>
</Header>
</>
);
}

View File

@ -1,48 +1,18 @@
import AddFriendDrawer from "@/app/_components/friend/add-friend-drawer";
import FriendCard from "@/app/_components/friend/friend-card";
import PendingRequestButton from "@/app/_components/friend/pending-request-button";
import FriendList from "@/app/_components/friend/friend-list";
import Header from "@/components/header";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/server";
import Link from "next/link";
import { api, HydrateClient } from "@/trpc/server";
import React from "react";
export default async function Page() {
const friendResult = await api.friend.getAll();
const pendingFriends = friendResult.filter((f) => f.status === "pending");
const friends = friendResult.filter((f) => f.status === "accepted");
void api.friend.getAll.prefetch();
return (
<>
<HydrateClient>
<Header text="Friends">
<AddFriendDrawer />
</Header>
{pendingFriends.length ? (
<Section heading="Requests">
{pendingFriends.map(({ user, requestedBy, id }) => (
<FriendCard key={user.id} user={user}>
<PendingRequestButton
friendshipId={id}
requestedBy={requestedBy}
/>
</FriendCard>
))}
</Section>
) : null}
<Section heading={`Friends (${friends?.length ?? 0})`}>
{friends?.length ? (
friends.map(({ user }) => (
<FriendCard key={user.id} user={user}>
<Button size={"sm"} className="ml-auto" asChild>
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
</Button>
</FriendCard>
))
) : (
<p className="text-muted-foreground text-sm">No friends yet</p>
)}
</Section>
</>
<FriendList />
</HydrateClient>
);
}

View File

@ -1,5 +1,13 @@
import CreateGroupDrawer from "@/app/_components/group/create-group-drawer";
import Header from "@/components/header";
import React from "react";
export default function Page() {
return <div>Page</div>;
return (
<>
<Header text="Groups">
<CreateGroupDrawer />
</Header>
</>
);
}

View File

@ -11,8 +11,10 @@ export default async function Layout({
const session = await auth();
if (!session?.user) return redirect("/api/auth/signin");
return (
<div className="space-y-2 w-full mx-auto max-w bg-muted/25 h-screen flex flex-col justify-between">
<main className="space-y-4 ">{children}</main>
<div className="space-y-2 w-full mx-auto max-w h-screen flex flex-col justify-between bg-card/25 overflow-hidden">
<main className="space-y-4 rounded-b-[3rem] bg-background h-full border border-card z-20 relative overflow-y-auto pb-8">
{children}
</main>
<Navbar />
</div>
);

View File

@ -1,13 +1,18 @@
import { appConfig } from "@/app.config";
import Header from "@/components/header";
import { ModeToggle } from "@/components/mode-toggle";
import Section from "@/components/section";
import { auth } from "@/server/auth";
import UserDropdown from "../_components/user-dropdown";
export default async function Home() {
const session = await auth();
return (
<>
<Header text={appConfig.name}>
<ModeToggle />
<UserDropdown user={session?.user!} />
</Header>
<Section></Section>
</>
);
}

View File

@ -15,33 +15,92 @@ import {
} from "@/components/ui/form";
import { expenseSchema } from "@/lib/validations/expense";
import { Textarea } from "@/components/ui/textarea";
import { MoneyInput } from "@/components/number-input";
import FriendSelect from "../friend/friend-select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
function ExpenseForm({ initialExpense }: { initialExpense?: Expense }) {
import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import ExpenseSplit from "./expense-split";
import type { Session } from "next-auth";
import { useExpenseSplit } from "@/hooks/use-expense-split";
import { toast } from "sonner";
import { api } from "@/trpc/react";
function ExpenseForm({
initialExpense,
session,
hideSubmit,
}: {
initialExpense?: Expense;
session: Session;
hideSubmit?: boolean;
}) {
const createExpense = api.expense.create.useMutation();
const form = useForm<z.infer<typeof expenseSchema>>({
resolver: zodResolver(expenseSchema),
defaultValues: {
description: initialExpense?.description ?? "",
amount: initialExpense?.amount ?? 0,
groupId: initialExpense?.groupId ?? "",
},
});
const amount = form.watch("amount");
const expenseSplitHook = useExpenseSplit(amount, session);
function onSubmit(values: z.infer<typeof expenseSchema>) {
console.log(values);
if (expenseSplitHook.participants.length <= 1)
return toast.error("Please add at least 2 participants");
if (expenseSplitHook.splitType !== "Equal")
return toast.error("You can only split equal for now.");
toast.success("Expense created!");
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 ">
<div className="flex items-center">
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
id="expense-form"
>
{!hideSubmit && (
<Button type="submit" className="ml-auto">
{initialExpense?.id?.length ? "Update" : "Create"} Expense
</Button>
</div>
<Tabs defaultValue="expense" className="size-full space-y-4">
)}
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel> Amount</FormLabel>
<div className="group flex border-b gap-2 px-4 py-2 border-input items-center ">
<NumberInput
value={field.value}
onChange={field.onChange}
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 p-0"
/>
<EuroIcon className="size-6 text-muted-foreground" />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-muted-foreground border-0 border-b rounded-none resize-none"
placeholder="About this expense"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ExpenseSplit expenseSplitHook={expenseSplitHook} session={session} />
{/* <Tabs defaultValue="expense" className="size-full space-y-4">
<TabsList className="w-full bg-transparent p-0">
<TabsTrigger value="expense">Expense</TabsTrigger>
<TabsTrigger value="split">Split</TabsTrigger>
@ -49,35 +108,48 @@ function ExpenseForm({ initialExpense }: { initialExpense?: Expense }) {
<TabsContent value="expense" className="space-y-8 size-full">
<FormField
control={form.control}
name="description"
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="About this expense" {...field} />
</FormControl>
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel> Amount</FormLabel>
{/* <MoneyInput
onChange={field.onChange}
showNumpad={true}
className="absolute w-full max-w left-1/2 -translate-x-1/2 transform bottom-20 px-4"
/>
<div className="flex border-b gap-2 px-4 py-2 border-input items-center">
<NumberInput
value={field.value}
onChange={field.onChange}
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 p-0"
/>
<EuroIcon className="size-6 text-muted-foreground" />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
name="description"
render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel> Amount: {field.value?.toFixed(2)}</FormLabel>
<MoneyInput
onChange={field.onChange}
showNumpad={true}
className="absolute w-full max-w left-1/2 -translate-x-1/2 transform bottom-20 px-4"
/>
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 border-b rounded-none resize-none"
placeholder="About this expense"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ExpenseSplit amount={amount} session={session} />
</TabsContent>
<TabsContent value="split">
<FormField
{/* <FormField
control={form.control}
name="friendId"
render={({ field }) => (
@ -92,9 +164,9 @@ function ExpenseForm({ initialExpense }: { initialExpense?: Expense }) {
<FormMessage />
</FormItem>
)}
/>
/>
</TabsContent>
</Tabs>
</Tabs> */}
</form>
</Form>
);

View File

@ -0,0 +1,148 @@
"use client";
import React from "react";
import FriendSelect from "../friend/friend-select";
import { Button } from "@/components/ui/button";
import type { Session } from "next-auth";
import FriendCard from "../friend/friend-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Icons } from "@/components/icons";
import {
splitTypeKeys,
type ExpenseSplitHook,
type SplitType,
} from "@/hooks/use-expense-split";
import PaidByInput from "./paid-by";
import { Separator } from "@radix-ui/react-select";
export default function ExpenseSplit({
expenseSplitHook,
session,
}: {
expenseSplitHook: ExpenseSplitHook;
session: Session;
}) {
const {
friends,
splitType,
setSplitType,
friendTarget,
setFriendTarget,
addParticipant,
removeParticipant,
participants,
splits,
} = expenseSplitHook;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PaidByInput
expenseSplitHook={expenseSplitHook}
sessionUser={session.user}
/>
</div>
<Separator className=" bg-primary w-full h-px my-12" />
<Select
value={splitType}
onValueChange={(currentValue) =>
setSplitType(currentValue as SplitType)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Split" />
</SelectTrigger>
<SelectContent>
{splitTypeKeys.map((splitType) => (
<SelectItem key={splitType} value={splitType}>
Split {splitType}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2 items-center w-full">
<FriendSelect
className="grow"
onSelect={setFriendTarget}
initialValue={friendTarget}
excludeIds={participants.map((user) => user.id!)}
/>
<Button
variant={"outline"}
size={"icon"}
onClick={(e) => {
e.preventDefault();
if (!friendTarget) return;
const newUser = friends.find(
(friend) => friend.user.id === friendTarget
)!;
addParticipant(newUser.user!);
setFriendTarget(undefined);
}}
>
<Icons.add className="size-4" />
</Button>
</div>
<h4 className="text-lg">Owed to</h4>
<ul className="space-y-2">
{participants.map((user) => (
<li key={user.id}>
<FriendCard
user={user}
className="border bg-background relative pr-6"
>
{session.user.id !== user.id ? (
<Button
variant={"destructive"}
className="p-0 w-6 h-6 rounded-full absolute top-1/2 -translate-y-1/2 transform -right-2"
onClick={(e) => {
e.preventDefault();
removeParticipant(user.id!);
}}
>
<Icons.trash className="size-4" />
</Button>
) : null}
<div className="flex ml-auto">
<p className="text-muted-foreground">{splits[user.id!]}</p>
</div>
</FriendCard>
</li>
))}
</ul>
<h4 className="text-lg">Paid from</h4>
<ul className="space-y-2">
{participants.map((user) => (
<li key={user.id}>
<FriendCard
user={user}
className="border bg-background relative pr-6"
>
{session.user.id !== user.id ? (
<Button
variant={"destructive"}
className="p-0 w-6 h-6 rounded-full absolute top-1/2 -translate-y-1/2 transform -right-2"
onClick={(e) => {
e.preventDefault();
removeParticipant(user.id!);
}}
>
<Icons.trash className="size-4" />
</Button>
) : null}
<div className="flex ml-auto">
<p className="text-muted-foreground">{splits[user.id!]}</p>
</div>
</FriendCard>
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,86 @@
"use client";
import type { Payment } from "@/lib/utils/expense";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar";
import React from "react";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function PaidByCustomSplit({
users,
payments,
}: {
users: Array<PublicUser>;
payments: Array<Payment>;
}) {
const [customAmounts, setCustomAmounts] = React.useState<
Record<string, number>
>({});
const error = Object.values(customAmounts).reduce(
(acc, curr) => acc + curr,
0
);
console.log(error);
return (
<Dialog>
<DialogTrigger className="mt-4">Multiple Friends</DialogTrigger>
<DialogContent className=" absolute size-full sm:max-w" hideClose>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
<DialogClose asChild>
<Button className="ml-auto">Done</Button>
</DialogClose>
</div>
{users.map((user) => {
return (
<div
key={user.id}
className="p-4 flex w-full h-max rounded-2xl border justify-between gap-2 overflow-hidden "
>
<div className="flex items-center gap-2">
<Avatar
className={cn("size-8 transition-transform duration-300")}
src={user.image}
fb={user.name}
/>
<span className={cn(" transition-all duration-300")}>
{user.name}
</span>
</div>
<div className="flex items-center gap-1 border-b">
<EuroIcon className="size-4" />
<NumberInput
value={customAmounts[user.id!] ?? 0.0}
onChange={(value) =>
setCustomAmounts((prev) => ({
...prev,
[user.id!]: value,
}))
}
className="w-20 rounded-none border-0"
/>
</div>
</div>
);
})}
</DialogHeader>
</DialogContent>
</Dialog>
);
}
export default PaidByCustomSplit;

View File

@ -0,0 +1,95 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar";
import type { ExpenseSplitHook } from "@/hooks/use-expense-split";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import PaidByCustomSplit from "./paid-by-custom-split";
/* saved result is an array of objects with user and amount */
export default function PaidByInput({
sessionUser,
expenseSplitHook,
}: {
sessionUser: PublicUser;
expenseSplitHook: ExpenseSplitHook;
}) {
const { amount, payments, setPayments, participants } = expenseSplitHook;
return (
<Dialog>
<DialogTrigger className="size-full flex items-center flex-wrap gap-2">
Paid by
<div className="py-1 rounded-md px-4 ">
{payments.length > 1 ? (
"2+ Users"
) : (
<span className="text-xl">
{payments.find(({ userId }) => userId === sessionUser.id)
? "You"
: participants.find(({ id }) => id === payments[0]?.userId)
?.name}
</span>
)}
</div>
</DialogTrigger>
<DialogContent className=" absolute size-full sm:max-w" hideClose>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
<DialogClose asChild>
<Button className="ml-auto">Go Back</Button>
</DialogClose>
</div>
{participants.map((user) => {
const selected = payments.find(({ userId }) => userId === user.id);
return (
<Button
variant={"none"}
key={user.id}
className="p-4 w-full h-max rounded-2xl border justify-between gap-2 overflow-hidden "
onClick={() =>
setPayments([{ amount: amount, userId: user.id! }])
}
>
<div className="flex items-center gap-2">
<Avatar
className={cn(
"size-8 transition-transform duration-300",
selected && "scale-[225%]"
)}
src={user.image}
fb={user.name}
/>
<span
className={cn(
" transition-all duration-300",
selected && "translate-x-6 text-lg"
)}
>
{user.name}
</span>
</div>
{payments.length === 1 && selected && (
<Icons.check className="ml-auto size-8" />
)}
</Button>
);
})}
<PaidByCustomSplit payments={payments} users={participants} />
</DialogHeader>
</DialogContent>
</Dialog>
);
}

View File

@ -1,7 +0,0 @@
import React from "react";
function SplitExpense() {
return <div>SplitExpense</div>;
}
export default SplitExpense;

View File

@ -45,6 +45,7 @@ const SearchResultCard = ({
};
export default function AddFriendDrawer() {
const utils = api.useUtils();
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [searchValue] = useDebounce(value, 1000);
@ -65,7 +66,8 @@ export default function AddFriendDrawer() {
onSuccess() {
toast.success("Friend request sent!");
setOpen(false);
refetch();
setValue("");
utils.friend.getAll.invalidate();
},
});
const handleAddFriend = (userId: string) => {
@ -88,6 +90,7 @@ export default function AddFriendDrawer() {
autoFocus
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder="Search for a friend"
/>
<ul>
{isFetching ? (

View File

@ -1,16 +1,24 @@
import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils";
import type { PublicUser } from "next-auth";
import React from "react";
export default function FriendCard({
user: { name, image },
children,
className,
}: {
user: PublicUser;
children?: React.ReactNode;
className?: string;
}) {
return (
<div className="flex items-center gap-2 p-2 hover:bg-accent/25 rounded-md ">
<div
className={cn(
"flex items-center gap-2 p-2 hover:bg-accent/25 rounded-md",
className
)}
>
<Avatar fb={name} src={image} />
<span>{name}</span>
{children}

View File

@ -0,0 +1,50 @@
"use client";
import React from "react";
import FriendCard from "@/app/_components/friend/friend-card";
import PendingRequestButton from "@/app/_components/friend/pending-request-button";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { api } from "@/trpc/react";
function FriendList() {
const [friendResult] = api.friend.getAll.useSuspenseQuery();
const pendingFriends = friendResult.filter((f) => f.status === "pending");
const friends = friendResult.filter((f) => f.status === "accepted");
return (
<>
{pendingFriends.length ? (
<Section heading="Requests">
{pendingFriends.map(({ user, requestedBy, id }) => (
<FriendCard key={user.id} user={user}>
<PendingRequestButton
friendshipId={id}
requestedBy={requestedBy}
/>
</FriendCard>
))}
</Section>
) : null}
<Section heading={`Friends (${friends?.length ?? 0})`}>
{friends?.length ? (
friends.map(({ user }) => (
<FriendCard key={user.id} user={user}>
<Button
size={"sm"}
className="ml-auto"
variant={"outline"}
asChild
>
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
</Button>
</FriendCard>
))
) : (
<p className="text-muted-foreground text-sm">No friends yet</p>
)}
</Section>
</>
);
}
export default FriendList;

View File

@ -1,6 +1,7 @@
"use client";
import Avatar from "@/components/avatar";
import { Combobox } from "@/components/combobox";
import { Icons } from "@/components/icons";
import { api } from "@/trpc/react";
import React from "react";
@ -8,22 +9,35 @@ import React from "react";
export default function FriendSelect({
onSelect,
initialValue,
className,
excludeIds,
}: {
onSelect: (value?: string) => void;
initialValue?: string;
className?: string;
excludeIds?: Array<string>;
}) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const data = friends
.filter((friend) => !excludeIds?.includes(friend.user.id))
.map(({ user }) => ({
label: user.name!,
value: user.id,
children: <Avatar src={user.image} fb={user.name} className="size-6" />,
}));
return (
<Combobox
className="w-full"
data={friends.map(({ user }) => ({
label: user.name!,
value: user.id,
children: <Avatar src={user.image} fb={user.name} className="size-6" />,
}))}
className={className}
data={data}
onSelect={onSelect}
initialValue={initialValue}
messageUi={{
select: "Select Friend",
empty: "No friends found",
placeholder: "Search friends",
}}
/>
);
}

View File

@ -11,20 +11,23 @@ export default function PendingRequestButton({
friendshipId: string;
requestedBy: string;
}) {
const utils = api.useUtils();
const handleSuccess = () => {
toast.success(
`Friend request ${requestedBy === "them" ? "accepted" : "cancelled"}`
);
utils.friend.getAll.invalidate();
};
const acceptRequest = api.friend.accept.useMutation({
onSuccess() {
toast.success("Friend request accepted!");
},
onSuccess: handleSuccess,
});
const cancleRequest = api.friend.cancel.useMutation({
onSuccess() {
toast.success("Friend request cancelled!");
},
onSuccess: handleSuccess,
});
return (
<Button
variant={requestedBy === "me" ? "destructive" : "outline"}
variant={requestedBy === "me" ? "destructive" : "default"}
className="ml-auto"
size="sm"
onClick={() => {

View File

@ -0,0 +1,34 @@
"use client";
import React from "react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
export default function CreateGroupDrawer() {
const [open, setOpen] = React.useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button size={"sm"}>Create Group</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create Group</DrawerTitle>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { User } from "next-auth";
import Avatar from "@/components/avatar";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons";
import Link from "next/link";
export default function UserDropdown({ user }: { user: User }) {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar fb={user.name} src={user.image} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-between " asChild>
<Link href={"/me"}>
Profile
<Icons.user className="size-4 text-foreground" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="justify-between " asChild>
<Link href={"/billing"}>
Billing
<Icons.wallet className="size-4 text-foreground" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-between text-destructive" asChild>
<Link href={"/api/auth/signout"}>
Logout
<Icons.logout className="size-4 text-destructive" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -31,7 +31,7 @@ export default function RootLayout({
disableTransitionOnChange
>
{children}
<Toaster />
<Toaster position="top-center" />
</ThemeProvider>
</TRPCReactProvider>
</body>

View File

@ -18,6 +18,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { IconComponent } from "./icons";
export type ComboboxProps = {
data: {
@ -29,7 +30,7 @@ export type ComboboxProps = {
onSelect: (value?: string) => void;
messageUi?: {
select?: string;
selectIcon?: LucideIcon;
selectIcon?: LucideIcon | IconComponent;
empty?: string;
placeholder?: string;
};

View File

@ -1,16 +1,19 @@
import React from "react";
import { ModeToggle } from "@/components/mode-toggle";
export default function Header({
text,
children,
glow = true,
}: {
text: string;
children?: React.ReactNode;
glow?: boolean;
}) {
return (
<div className="w-full p-4 border-b flex justify-between items-center">
<h2 className="font-bold text-xl ">{text}</h2>
<div className="w-full p-4 border-b flex justify-between items-center relative">
<h2 className="font-black text-2xl uppercase">{text}</h2>
<div className="glow absolute right-0 top-0 w-20 h-8 bg-primary blur-2xl -z-10" />
{children}
</div>
);

286
src/components/icons.tsx Normal file
View File

@ -0,0 +1,286 @@
export type IconComponent = React.FC<React.SVGProps<SVGSVGElement>>;
type IconName =
| "logo"
| "home"
| "wallet"
| "add"
| "group"
| "friends"
| "logout"
| "login"
| "trash"
| "user"
| "addSquare"
| "check";
export const Icons: Record<IconName, IconComponent> = {
logo(props) {
return (
<svg
width="78"
height="32"
viewBox="0 0 78 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M55.5 0H77.5L58.5 32H36.5L55.5 0Z"
fill="currentColor"
fillOpacity={0.65}
/>
<path
d="M35.5 0H51.5L32.5 32H16.5L35.5 0Z"
fill="currentColor"
fillOpacity={0.85}
/>
<path d="M19.5 0H31.5L12.5 32H0.5L19.5 0Z" fill="currentColor" />
</svg>
);
},
home(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6.29367 4.96556C8.96048 3.028 10.2939 2.05923 11.8167 2.00336C11.9389 1.99888 12.0611 1.99888 12.1833 2.00336C13.7061 2.05923 15.0395 3.028 17.7063 4.96556C20.3732 6.90311 21.7066 7.87189 22.2303 9.30291C22.2723 9.41771 22.3101 9.534 22.3436 9.65157C22.761 11.1171 22.2517 12.6846 21.2331 15.8197L19.512 21.1164C19.2386 21.958 18.4543 22.5279 17.5694 22.5279C16.4412 22.5279 15.5267 21.6133 15.5267 20.4852V17.7363C15.5267 16.6778 14.6686 15.8197 13.6101 15.8197H10.3899C9.3314 15.8197 8.47329 16.6778 8.47329 17.7363V20.4852C8.47329 21.6133 7.55877 22.5279 6.43065 22.5279C5.54572 22.5279 4.76144 21.958 4.48798 21.1164L2.76696 15.8197C1.74832 12.6846 1.23901 11.1171 1.65645 9.65157C1.68994 9.534 1.72773 9.41771 1.76974 9.30291C2.29344 7.87189 3.62685 6.90311 6.29367 4.96556Z"
fill="currentColor"
strokeWidth="1.5"
/>
</svg>
);
},
wallet(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M13.0336 3.25C14.4053 3.25 15.4807 3.24999 16.3451 3.32061C17.2252 3.39252 17.9523 3.54138 18.6104 3.87671C19.6924 4.42798 20.572 5.30762 21.1233 6.38955C21.5254 7.17877 21.6643 8.08233 21.7165 9.25H2.28354C2.33571 8.08233 2.47459 7.17877 2.87671 6.38955C3.42798 5.30762 4.30762 4.42798 5.38955 3.87671C6.04769 3.54138 6.77479 3.39252 7.65494 3.32061C8.51929 3.24999 9.59472 3.25 10.9664 3.25H13.0336Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.25227 10.75C2.25 11.1341 2.25 11.548 2.25 11.9942V12.0336C2.25 13.4053 2.24999 14.4807 2.32061 15.3451C2.39252 16.2252 2.54138 16.9523 2.87671 17.6104C3.42798 18.6924 4.30762 19.572 5.38955 20.1233C6.04769 20.4586 6.77479 20.6075 7.65494 20.6794C8.51924 20.75 9.59461 20.75 10.9662 20.75H13.0336C14.4052 20.75 15.4808 20.75 16.3451 20.6794C17.2252 20.6075 17.9523 20.4586 18.6104 20.1233C19.6924 19.572 20.572 18.6924 21.1233 17.6104C21.4586 16.9523 21.6075 16.2252 21.6794 15.3451C21.75 14.4808 21.75 13.4054 21.75 12.0338V11.9942C21.75 11.548 21.75 11.1341 21.7477 10.75H2.25227ZM12.25 17C12.25 16.5858 12.5858 16.25 13 16.25H17C17.4142 16.25 17.75 16.5858 17.75 17C17.75 17.4142 17.4142 17.75 17 17.75H13C12.5858 17.75 12.25 17.4142 12.25 17Z"
fill="currentColor"
/>
</svg>
);
},
add(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12.75 4C12.75 3.58579 12.4142 3.25 12 3.25C11.5858 3.25 11.25 3.58579 11.25 4L11.25 11.25H4C3.58579 11.25 3.25 11.5858 3.25 12C3.25 12.4142 3.58579 12.75 4 12.75H11.25V20C11.25 20.4142 11.5858 20.75 12 20.75C12.4142 20.75 12.75 20.4142 12.75 20V12.75H20C20.4142 12.75 20.75 12.4142 20.75 12C20.75 11.5858 20.4142 11.25 20 11.25H12.75L12.75 4Z"
fill="currentColor"
/>
</svg>
);
},
group(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M8.65063 10.3681C7.78554 9.50781 7.25 8.31645 7.25 7C7.25 5.68355 7.78554 4.49219 8.65063 3.63188C8.15256 3.38733 7.59232 3.25 7 3.25C4.92893 3.25 3.25 4.92893 3.25 7C3.25 9.07107 4.92893 10.75 7 10.75C7.59232 10.75 8.15256 10.6127 8.65063 10.3681Z"
fill="currentColor"
/>
<path
d="M8.25 7C8.25 4.92893 9.92893 3.25 12 3.25C14.0711 3.25 15.75 4.92893 15.75 7C15.75 9.07107 14.0711 10.75 12 10.75C9.92893 10.75 8.25 9.07107 8.25 7Z"
fill="currentColor"
/>
<path
d="M15.3494 3.63188C16.2145 4.49219 16.75 5.68355 16.75 7C16.75 8.31645 16.2145 9.50781 15.3494 10.3681C15.8474 10.6127 16.4077 10.75 17 10.75C19.0711 10.75 20.75 9.07107 20.75 7C20.75 4.92893 19.0711 3.25 17 3.25C16.4077 3.25 15.8474 3.38733 15.3494 3.63188Z"
fill="currentColor"
/>
<path
d="M5.25 16C5.25 13.9289 6.92893 12.25 9 12.25H15C17.0711 12.25 18.75 13.9289 18.75 16C18.75 18.0711 17.0711 19.75 15 19.75H9C6.92893 19.75 5.25 18.0711 5.25 16Z"
fill="currentColor"
/>
<path
d="M0.25 15C0.25 12.9289 1.92893 11.25 4 11.25H9C6.37665 11.25 4.25 13.3766 4.25 16C4.25 17.0249 4.57458 17.9739 5.12655 18.75H4C1.92893 18.75 0.25 17.0711 0.25 15Z"
fill="currentColor"
/>
<path
d="M19.75 16C19.75 17.0249 19.4254 17.9739 18.8734 18.75H20C22.0711 18.75 23.75 17.0711 23.75 15C23.75 12.9289 22.0711 11.25 20 11.25H15C17.6234 11.25 19.75 13.3766 19.75 16Z"
fill="currentColor"
/>
</svg>
);
},
friends(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.5 7.75C18.5 5.67893 16.8211 4 14.75 4C12.6789 4 11 5.67893 11 7.75C11 9.82107 12.6789 11.5 14.75 11.5C16.8211 11.5 18.5 9.82107 18.5 7.75Z"
fill="currentColor"
/>
<path
d="M11.4006 4.38188C10.5355 5.24219 10 6.43355 10 7.75C10 9.06645 10.5355 10.2578 11.4006 11.1181C10.9026 11.3627 10.3423 11.5 9.75 11.5C7.67893 11.5 6 9.82107 6 7.75C6 5.67893 7.67893 4 9.75 4C10.3423 4 10.9026 4.13733 11.4006 4.38188Z"
fill="currentColor"
/>
<path
d="M21.5 16.75C21.5 14.6789 19.8211 13 17.75 13H11.75C9.67893 13 8 14.6789 8 16.75C8 18.8211 9.67893 20.5 11.75 20.5H17.75C19.8211 20.5 21.5 18.8211 21.5 16.75Z"
fill="currentColor"
/>
<path
d="M7 16.75C7 17.7749 7.32458 18.7239 7.87655 19.5H6.75C4.67893 19.5 3 17.8211 3 15.75C3 13.6789 4.67893 12 6.75 12H11.75C9.12665 12 7 14.1266 7 16.75Z"
fill="currentColor"
/>
</svg>
);
},
logout(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 11.25C6.58579 11.25 6.25 11.5858 6.25 12C6.25 12.4142 6.58579 12.75 7 12.75L15 12.75L15 15C15 15.929 15 16.3935 14.9384 16.7822C14.5996 18.9216 12.9216 20.5996 10.7822 20.9384C10.3935 21 9.929 21 9 21C8.07099 21 7.60649 21 7.21783 20.9384C5.07836 20.5996 3.40041 18.9216 3.06156 16.7822C3 16.3935 3 15.929 3 15L3 9C3 8.07099 3 7.60649 3.06156 7.21783C3.40042 5.07836 5.07837 3.40042 7.21783 3.06156C7.60649 3 8.07099 3 9 3C9.92901 3 10.3935 3 10.7822 3.06156C12.9216 3.40042 14.5996 5.07836 14.9384 7.21783C15 7.60649 15 8.07099 15 9L15 11.25L7 11.25ZM15 11.25L19.8105 11.25C19.483 10.9273 19.001 10.5437 18.297 9.98553L16.534 8.58768C16.2095 8.33034 16.155 7.8586 16.4123 7.53403C16.6697 7.20946 17.1414 7.15497 17.466 7.41232L19.2648 8.83857C19.9372 9.37175 20.4922 9.81172 20.8875 10.2055C21.2932 10.6096 21.6294 11.0582 21.7208 11.6313C21.7402 11.7534 21.75 11.8766 21.75 12C21.75 12.1234 21.7402 12.2466 21.7208 12.3687C21.6294 12.9418 21.2932 13.3904 20.8875 13.7945C20.4922 14.1883 19.9373 14.6282 19.2648 15.1614L17.466 16.5877C17.1414 16.845 16.6697 16.7905 16.4123 16.466C16.155 16.1414 16.2095 15.6697 16.534 15.4123L18.297 14.0145C19.001 13.4563 19.483 13.0727 19.8105 12.75L15 12.75L15 11.25Z"
fill="currentColor"
/>
</svg>
);
},
login(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.466 7.41232C13.1414 7.15497 12.6697 7.20946 12.4123 7.53403C12.155 7.8586 12.2095 8.33034 12.534 8.58768L14.297 9.98553C15.001 10.5437 15.483 10.9273 15.8105 11.25H9V9C9 8.07099 9 7.60649 9.06156 7.21783C9.40042 5.07837 11.0784 3.40042 13.2178 3.06156C13.6065 3 14.071 3 15 3C15.929 3 16.3935 3 16.7822 3.06156C18.9216 3.40042 20.5996 5.07836 20.9384 7.21783C21 7.60649 21 8.07099 21 9V15C21 15.929 21 16.3935 20.9384 16.7822C20.5996 18.9216 18.9216 20.5996 16.7822 20.9384C16.3935 21 15.929 21 15 21C14.071 21 13.6065 21 13.2178 20.9384C11.0784 20.5996 9.40042 18.9216 9.06156 16.7822C9 16.3935 9 15.929 9 15V12.75H15.8105C15.483 13.0727 15.001 13.4563 14.297 14.0145L12.534 15.4123C12.2095 15.6697 12.155 16.1414 12.4123 16.466C12.6697 16.7905 13.1414 16.845 13.466 16.5877L15.2648 15.1614C15.9372 14.6282 16.4922 14.1883 16.8875 13.7945C17.2932 13.3904 17.6294 12.9418 17.7208 12.3687C17.7402 12.2466 17.75 12.1234 17.75 12C17.75 11.8766 17.7402 11.7534 17.7208 11.6313C17.6294 11.0582 17.2932 10.6096 16.8875 10.2055C16.4922 9.81172 15.9372 9.37175 15.2648 8.83857L13.466 7.41232ZM9 12.75L3 12.75C2.58579 12.75 2.25 12.4142 2.25 12C2.25 11.5858 2.58579 11.25 3 11.25L9 11.25V12.75Z"
fill="currentColor"
/>
</svg>
);
},
trash(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.2549 14.6645L18.5172 12.7795C18.5979 12.1995 18.6717 11.6693 18.7373 11.1829C18.8721 10.1834 18.9395 9.68367 18.6406 9.34184C18.3418 9 17.8282 9 16.801 9H7.19905C6.17182 9 5.65821 9 5.35938 9.34184C5.06054 9.68367 5.12793 10.1834 5.26272 11.1829C5.32833 11.6695 5.40207 12.1993 5.48281 12.7795L5.74515 14.6645C6.03026 16.7132 6.17282 17.7376 6.47509 18.5603C7.04034 20.0988 8.01744 21.2447 9.18366 21.7368C9.8073 22 10.5382 22 12 22C13.4618 22 14.1927 22 14.8164 21.7368C15.9826 21.2447 16.9597 20.0988 17.5249 18.5603C17.8272 17.7376 17.9698 16.7132 18.2549 14.6645ZM10.75 11C10.75 10.5858 10.4142 10.25 10 10.25C9.58579 10.25 9.25 10.5858 9.25 11V19C9.25 19.4142 9.58579 19.75 10 19.75C10.4142 19.75 10.75 19.4142 10.75 19V11ZM14.75 11C14.75 10.5858 14.4142 10.25 14 10.25C13.5858 10.25 13.25 10.5858 13.25 11V19C13.25 19.4142 13.5858 19.75 14 19.75C14.4142 19.75 14.75 19.4142 14.75 19V11Z"
fill="currentColor"
/>
<path
d="M12 1.25C9.37665 1.25 7.25 3.37665 7.25 6V6.25H4C3.58579 6.25 3.25 6.58579 3.25 7C3.25 7.41421 3.58579 7.75 4 7.75H20C20.4142 7.75 20.75 7.41421 20.75 7C20.75 6.58579 20.4142 6.25 20 6.25H16.75V6C16.75 3.37665 14.6234 1.25 12 1.25Z"
fill="currentColor"
/>
</svg>
);
},
user(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 2C9.37665 2 7.25 4.12665 7.25 6.75C7.25 9.37335 9.37665 11.5 12 11.5C14.6234 11.5 16.75 9.37335 16.75 6.75C16.75 4.12665 14.6234 2 12 2Z"
fill="currentColor"
/>
<path
d="M9 13C6.37665 13 4.25 15.1266 4.25 17.75C4.25 20.3734 6.37665 22.5 9 22.5H15C17.6234 22.5 19.75 20.3734 19.75 17.75C19.75 15.1266 17.6234 13 15 13H9Z"
fill="currentColor"
/>
</svg>
);
},
addSquare(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.95491 5.06107C3 6.3754 3 8.25027 3 12C3 15.7497 3 17.6246 3.95491 18.9389C4.26331 19.3634 4.6366 19.7367 5.06107 20.0451C6.3754 21 8.25027 21 12 21C15.7497 21 17.6246 21 18.9389 20.0451C19.3634 19.7367 19.7367 19.3634 20.0451 18.9389C21 17.6246 21 15.7497 21 12C21 8.25027 21 6.3754 20.0451 5.06107C19.7367 4.6366 19.3634 4.26331 18.9389 3.95491C17.6246 3 15.7497 3 12 3C8.25027 3 6.3754 3 5.06107 3.95491C4.6366 4.26331 4.26331 4.6366 3.95491 5.06107ZM12.75 9C12.75 8.58579 12.4142 8.25 12 8.25C11.5858 8.25 11.25 8.58579 11.25 9V11.25H9C8.58579 11.25 8.25 11.5858 8.25 12C8.25 12.4142 8.58579 12.75 9 12.75H11.25V15C11.25 15.4142 11.5858 15.75 12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V12.75H15C15.4142 12.75 15.75 12.4142 15.75 12C15.75 11.5858 15.4142 11.25 15 11.25H12.75V9Z"
fill="currentColor"
/>
</svg>
);
},
check(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M20.5761 7.48016C20.8413 7.16195 20.7983 6.68903 20.4801 6.42385C20.1619 6.15868 19.689 6.20167 19.4238 6.51988L14.0331 12.9887C12.9503 14.2881 12.1885 15.1994 11.5278 15.796C10.8826 16.3787 10.4372 16.5639 9.99996 16.5639C9.5627 16.5639 9.11736 16.3787 8.47207 15.796C7.81137 15.1994 7.04963 14.2881 5.9668 12.9887L4.57612 11.3199C4.31095 11.0017 3.83803 10.9587 3.51982 11.2239C3.20161 11.489 3.15862 11.9619 3.42379 12.2802L4.85306 13.9953C5.88833 15.2376 6.71742 16.2326 7.46678 16.9092C8.24083 17.6082 9.03209 18.0639 9.99996 18.0639C10.9678 18.0639 11.7591 17.6082 12.5331 16.9092C13.2825 16.2326 14.1116 15.2377 15.1468 13.9953L20.5761 7.48016Z"
fill="currentColor"
/>
</svg>
);
},
};

View File

@ -0,0 +1,190 @@
"use client";
import type React from "react";
import { useState, useRef } from "react";
import { Delete } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface MoneyInputProps {
placeholder?: string;
defaultValue?: string | number;
onChange?: (value: number | null) => void;
className?: string;
disabled?: boolean;
error?: string;
min?: number;
max?: number;
showNumpad?: boolean;
}
export function MoneyInput({
placeholder = "0.00",
defaultValue = "",
onChange,
className,
disabled = false,
error,
min,
max,
showNumpad = true,
}: MoneyInputProps) {
const [value, setValue] = useState(() => {
if (typeof defaultValue === "number") {
return defaultValue.toString();
}
return defaultValue;
});
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const updateValue = (newValue: string) => {
setValue(newValue);
const numericValue = newValue === "" ? null : Number.parseFloat(newValue);
onChange?.(numericValue);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Allow only numbers and decimal point
const inputValue = e.target.value.replace(/[^0-9.]/g, "");
// Ensure only one decimal point
const parts = inputValue.split(".")!;
let formattedValue = inputValue;
if (parts.length > 1) {
// Limit to 2 decimal places
formattedValue = `${parts[0]}.${parts[1]?.substring(0, 2)}`;
}
updateValue(formattedValue);
};
const handleNumpadClick = (digit: string) => {
let newValue = value;
// Handle decimal point - only add if there isn't one already
if (digit === "." && value.includes(".")) {
return;
}
// Check if we're adding a digit after the decimal point
const parts = value.split(".");
if (parts.length > 1 && parts[1]?.length! >= 2 && digit !== ".") {
// Already have 2 decimal places, don't add more digits
return;
}
// Append digit
newValue += digit;
updateValue(newValue);
inputRef.current?.focus();
};
const handleBackspace = () => {
if (value.length > 0) {
const newValue = value.slice(0, -1);
updateValue(newValue);
}
inputRef.current?.focus();
};
const handleClear = () => {
updateValue("");
inputRef.current?.focus();
};
// Focus the input when clicking on the container
const handleContainerClick = () => {
inputRef.current?.focus();
};
return (
<div className={cn("space-y-2", className)}>
<div
className={cn(
" overflow-hidden",
error ? "border-red-500" : "border-input"
)}
onClick={handleContainerClick}
>
<Input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={handleInputChange}
placeholder={placeholder}
className={cn(
"pl-7 border-0 focus-visible:ring-0 focus-visible:ring-offset-0",
error && "border-red-500 focus-visible:ring-red-500"
)}
disabled={disabled}
min={min}
max={max}
/>
{showNumpad && (
<div className="grid grid-cols-3 gap-1 mt-2">
{Array.from({ length: 9 }).map((_, idx) => {
const num = idx + 1;
return (
<Button
key={num}
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(num.toString())}
disabled={disabled}
>
{num}
</Button>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(".")}
disabled={disabled || value.includes(".")}
>
.
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick("0")}
disabled={disabled}
>
0
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg"
onClick={handleBackspace}
disabled={disabled || value.length === 0}
>
<Delete className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
}

View File

@ -2,25 +2,46 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import * as LucideIcons from "lucide-react";
import { Icons } from "./icons";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
function NavLink({ href, name, icon }: NavLink) {
const active = usePathname() === href;
const IconComponent = icon
? (LucideIcons[icon as keyof typeof LucideIcons] as LucideIcons.LucideIcon)
: null;
const IconComponent = icon ? Icons[icon as keyof typeof Icons] : null;
return (
<Button
asChild
size={"icon"}
variant={"ghost"}
className={cn(active && "text-primary bg-accent/25")}
>
<Link href={href}>{IconComponent && <IconComponent />}</Link>
</Button>
<div className="relative">
<Button
asChild
variant={"ghost"}
className={cn(
"p-2 size-12 text-muted-foreground/75 flex flex-col gap-2 ",
active && "text-foreground "
)}
>
<Link href={href}>
{IconComponent && (
<IconComponent
className={cn(
"size-6 transition-transform duration-300",
active && "scale-150"
)}
/>
)}
<span
className={cn(
"text-xs -translate-y-1 transition-transform duration-300",
active && "translate-y-0"
)}
>
{name}
</span>
</Link>
</Button>
{active && (
<div className="h-16 w-16 left-1/2 -z-10 -translate-x-1/2 transform blur-3xl absolute -bottom-8 bg-primary" />
)}
</div>
);
}

View File

@ -1,19 +1,17 @@
import React from "react";
import { ModeToggle } from "./mode-toggle";
import { appConfig } from "@/app.config";
import NavLink from "./nav-link";
export default function Navbar() {
return (
<nav className="flex items-center justify-between p-4">
<menu className="flex items-center gap-1 justify-around w-full">
<menu className="flex items-center gap-1 justify-around w-full ">
{appConfig.navigator.map((navItem, idx) => (
<li key={idx}>
<NavLink {...navItem} />
</li>
))}
</menu>
{/* <ModeToggle /> */}
</nav>
);
}

View File

@ -1,190 +1,80 @@
"use client";
import type React from "react";
import { useState, useRef } from "react";
import { Delete } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
interface MoneyInputProps {
placeholder?: string;
defaultValue?: string | number;
onChange?: (value: number | null) => void;
className?: string;
disabled?: boolean;
error?: string;
interface NumberInputProps
extends Omit<React.ComponentProps<"input">, "type" | "value" | "onChange"> {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
showNumpad?: boolean;
step?: number;
decimalPlaces?: number;
}
export function MoneyInput({
placeholder = "0.00",
defaultValue = "",
export function NumberInput({
value,
onChange,
className,
disabled = false,
error,
min,
max,
showNumpad = true,
}: MoneyInputProps) {
const [value, setValue] = useState(() => {
if (typeof defaultValue === "number") {
return defaultValue.toString();
}
return defaultValue;
});
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
step,
decimalPlaces = 2,
...props
}: NumberInputProps) {
const [stringValue, setStringValue] = useState(value.toString());
const updateValue = (newValue: string) => {
setValue(newValue);
const numericValue = newValue === "" ? null : Number.parseFloat(newValue);
onChange?.(numericValue);
};
// Sync internal string value when external value changes
useEffect(() => {
setStringValue(value.toString());
}, [value]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Allow only numbers and decimal point
const inputValue = e.target.value.replace(/[^0-9.]/g, "");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value?.startsWith(".")
? "0." + e.target.value
: e.target.value;
// Ensure only one decimal point
const parts = inputValue.split(".")!;
let formattedValue = inputValue;
if (parts.length > 1) {
// Limit to 2 decimal places
formattedValue = `${parts[0]}.${parts[1]?.substring(0, 2)}`;
}
updateValue(formattedValue);
};
const handleNumpadClick = (digit: string) => {
let newValue = value;
// Handle decimal point - only add if there isn't one already
if (digit === "." && value.includes(".")) {
// Allow empty string (to allow clearing the input)
if (!rawValue.length) {
setStringValue("");
onChange(0); // or you could use NaN or keep previous value
return;
}
const decimalRegex = new RegExp(`^-?\\d*(\\.\\d{0,${decimalPlaces}})?$`);
// Check if we're adding a digit after the decimal point
const parts = value.split(".");
if (parts.length > 1 && parts[1]?.length! >= 2 && digit !== ".") {
// Already have 2 decimal places, don't add more digits
return;
if (decimalRegex.test(rawValue)) {
setStringValue(rawValue);
// Only call onChange if the input is a complete valid number
if (/^-?\d+\.?\d*$/.test(rawValue)) {
const numValue = parseFloat(rawValue);
if (!isNaN(numValue)) {
// Round to specified decimal places
const roundedValue = parseFloat(numValue.toFixed(decimalPlaces));
onChange(roundedValue);
}
}
}
// Append digit
newValue += digit;
updateValue(newValue);
inputRef.current?.focus();
};
const handleBackspace = () => {
if (value.length > 0) {
const newValue = value.slice(0, -1);
updateValue(newValue);
}
inputRef.current?.focus();
const handleBlur = () => {
// When input loses focus, ensure the displayed value matches the stored number
setStringValue(value.toString());
};
const handleClear = () => {
updateValue("");
inputRef.current?.focus();
};
// Focus the input when clicking on the container
const handleContainerClick = () => {
inputRef.current?.focus();
};
const decimalPattern =
decimalPlaces > 0 ? `^-?\\d+(\\.\\d{0,${decimalPlaces}})?$` : `^-?\\d+$`;
return (
<div className={cn("space-y-2", className)}>
<div
className={cn(
" overflow-hidden",
error ? "border-red-500" : "border-input"
)}
onClick={handleContainerClick}
>
<Input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={handleInputChange}
placeholder={placeholder}
className={cn(
"pl-7 border-0 focus-visible:ring-0 focus-visible:ring-offset-0",
error && "border-red-500 focus-visible:ring-red-500"
)}
disabled={disabled}
min={min}
max={max}
/>
{showNumpad && (
<div className="grid grid-cols-3 gap-1 mt-2">
{Array.from({ length: 9 }).map((_, idx) => {
const num = idx + 1;
return (
<Button
key={num}
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(num.toString())}
disabled={disabled}
>
{num}
</Button>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick(".")}
disabled={disabled || value.includes(".")}
>
.
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg font-medium"
onClick={() => handleNumpadClick("0")}
disabled={disabled}
>
0
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-10 text-lg"
onClick={handleBackspace}
disabled={disabled || value.length === 0}
>
<Delete className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
<Input
{...props}
type="text" // 'tel' shows numeric keypad on mobile but allows more flexible input than 'number'
inputMode="numeric" // Ensures numeric keypad on mobile
pattern={decimalPattern} // Helps with mobile numeric input
value={stringValue}
onChange={handleChange}
onBlur={handleBlur}
min={min}
max={max}
step={step}
/>
);
}

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,
@ -18,7 +18,7 @@ function Avatar({
)}
{...props}
/>
)
);
}
function AvatarImage({
@ -31,7 +31,7 @@ function AvatarImage({
className={cn("aspect-square size-full", className)}
{...props}
/>
)
);
}
function AvatarFallback({
@ -47,7 +47,7 @@ function AvatarFallback({
)}
{...props}
/>
)
);
}
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
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",
"inline-flex cursor-pointer 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: {
@ -20,6 +20,7 @@ const buttonVariants = cva(
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
none: "bg-transparent text-foreground hover:bg-transparent focus-visible:border-0 focus-visible:ring-0 size-max p-0",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@ -1,6 +1,6 @@
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">) {
return (
@ -12,7 +12,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -58,7 +58,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
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,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,
@ -26,7 +26,7 @@ function Command({
)}
{...props}
/>
)
);
}
function CommandDialog({
@ -35,8 +35,8 @@ function CommandDialog({
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
@ -50,7 +50,7 @@ function CommandDialog({
</Command>
</DialogContent>
</Dialog>
)
);
}
function CommandInput({
@ -72,7 +72,7 @@ function CommandInput({
{...props}
/>
</div>
)
);
}
function CommandList({
@ -88,7 +88,7 @@ function CommandList({
)}
{...props}
/>
)
);
}
function CommandEmpty({
@ -100,7 +100,7 @@ function CommandEmpty({
className="py-6 text-center text-sm"
{...props}
/>
)
);
}
function CommandGroup({
@ -116,7 +116,7 @@ function CommandGroup({
)}
{...props}
/>
)
);
}
function CommandSeparator({
@ -129,7 +129,7 @@ function CommandSeparator({
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
);
}
function CommandItem({
@ -145,7 +145,7 @@ function CommandItem({
)}
{...props}
/>
)
);
}
function CommandShortcut({
@ -161,7 +161,7 @@ function CommandShortcut({
)}
{...props}
/>
)
);
}
export {
@ -174,4 +174,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
}
};

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({
@ -43,14 +43,17 @@ function DialogOverlay({
)}
{...props}
/>
)
);
}
function DialogContent({
className,
children,
hideClose,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
hideClose?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
@ -63,13 +66,15 @@ function DialogContent({
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{!hideClose && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -79,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -92,7 +97,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function DialogTitle({
@ -105,7 +110,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
@ -118,7 +123,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@ -132,4 +137,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};

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({
@ -48,7 +48,7 @@ function DropdownMenuContent({
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@ -56,7 +56,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@ -65,8 +65,8 @@ function DropdownMenuItem({
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,7 +148,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
@ -160,7 +160,7 @@ function DropdownMenuLabel({
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@ -173,7 +173,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@ -189,13 +189,13 @@ function DropdownMenuShortcut({
)}
{...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({
@ -235,7 +235,7 @@ function DropdownMenuSubContent({
)}
{...props}
/>
)
);
}
export {
@ -254,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};

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,27 +11,27 @@ 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>,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
@ -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,19 +62,19 @@ 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()
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
@ -84,14 +84,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
{...props}
/>
</FormItemContext.Provider>
)
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
const { error, formItemId } = useFormField();
return (
<Label
@ -101,11 +101,12 @@ function FormLabel({
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,11 +120,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
aria-invalid={!!error}
{...props}
/>
)
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
const { formDescriptionId } = useFormField();
return (
<p
@ -132,15 +133,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
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
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null
return null;
}
return (
@ -152,7 +153,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
>
{body}
</p>
)
);
}
export {
@ -164,4 +165,4 @@ export {
FormDescription,
FormMessage,
FormField,
}
};

View File

@ -9,7 +9,6 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex 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
)}

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,
@ -18,7 +18,7 @@ function Label({
)}
{...props}
/>
)
);
}
export { Label }
export { Label };

View File

@ -1,20 +1,20 @@
"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({
@ -36,13 +36,13 @@ function PopoverContent({
{...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

@ -0,0 +1,185 @@
"use client";
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";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground dark:bg-background [&_svg:not([class*='text-'])]: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:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<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",
className
)}
position={position}
{...props}
>
<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"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

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

View File

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
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 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,
@ -15,7 +15,7 @@ function Tabs({
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
);
}
function TabsList({
@ -31,7 +31,7 @@ function TabsList({
)}
{...props}
/>
)
);
}
function TabsTrigger({
@ -47,7 +47,7 @@ function TabsTrigger({
)}
{...props}
/>
)
);
}
function TabsContent({
@ -60,7 +60,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,81 @@
"use client";
import React from "react";
import { api } from "@/trpc/react";
import type { Session } from "next-auth";
import type { PublicUser } from "next-auth";
import { calculateSplits, type Payment } from "@/lib/utils/expense";
export enum SplitTypeEnum {
Fixed = "fixed",
Percentage = "percentage",
Equal = "equal",
}
export type SplitType = keyof typeof SplitTypeEnum;
export const splitTypeKeys = Object.keys(SplitTypeEnum);
export const useExpenseSplit = (amount: number, session: Session) => {
const [friends] = api.friend.getAll.useSuspenseQuery();
const [friendTarget, setFriendTarget] = React.useState<string>();
const [participants, setParticipants] = React.useState<Array<PublicUser>>([
session.user,
]);
const [activeSplitType, setActiveSplitType] =
React.useState<SplitType>("Equal");
const [payments, setPayments] = React.useState<Array<Payment>>([
{ amount: amount, userId: session.user.id },
]);
const [splits, setSplits] = React.useState<
Record<string /* user.id */, string /* amount */>
>({});
React.useEffect(() => {
// const generateSplitsAmount = () => {
// var newSplits: Record<string, SplitType> = {};
// participants.forEach((user) => {
// newSplits[user.id!] = splits[user.id!] ?? {};
// });
// console.log(newSplits);
// return newSplits;
// };
setSplits(
calculateSplits(
{
amount,
users: participants.map((user) => user.id!),
splitType: activeSplitType,
}
// generateSplitsAmount()
)
);
}, [participants, activeSplitType, amount]);
const addParticipant = (newUser: PublicUser) => {
setParticipants((prev) =>
prev.find((user) => user.id === newUser.id) ? prev : [...prev, newUser]
);
};
const removeParticipant = (userId: string) => {
setParticipants((prev) => prev.filter((user) => user.id !== userId));
};
return {
friends,
amount,
participants,
addParticipant,
removeParticipant,
friendTarget,
setFriendTarget,
splitType: activeSplitType,
setSplitType: setActiveSplitType,
payments,
setPayments,
//
splits,
};
};
export type ExpenseSplitHook = ReturnType<typeof useExpenseSplit>;

View File

@ -3,4 +3,5 @@ type NavLink = {
name: string;
href: string;
icon?: string;
big?: boolean;
};

117
src/lib/utils/expense.ts Normal file
View File

@ -0,0 +1,117 @@
import type { SplitType } from "@/hooks/use-expense-split";
export type Debt = {
from: string;
to: string;
amount: number;
};
export type Payment = {
userId: string;
amount: number;
};
export function calculateSplits(args: {
amount: number;
users: Array<string>;
splitType: SplitType;
payments?: Array<Payment>;
}): Record<string, string> {
const result: Record<string, string> = {};
const { splitType = "Equal", amount, users, payments } = args;
// If splits are provided, validate them
// const amount = Object.values(payments).reduce((sum, value) => sum + value, 0);
switch (splitType) {
case "Equal":
var splits: Record<string, string> = {};
users.forEach((userId) => {
splits[userId] = `${amount / users.length}`;
});
return splits;
// case "Percentage":
// if (!customDepts) {
// throw new Error("Splits must be provided for percentage type.");
// }
// participants.forEach((userId) => {
// if (customDepts[userId] !== undefined) {
// const percentage = customDepts[userId] / 100;
// const amountSplit = amount * percentage;
// result[userId] = `${amountSplit.toFixed(2)}%`; // Add "%" for percentage
// } else {
// throw new Error(`No percentage split defined for user ID: ${userId}`);
// }
// });
// break;
// case "Fixed":
// if (!customDepts) {
// throw new Error("Splits must be provided for fixed type.");
// }
// participants.forEach((userId) => {
// if (customDepts[userId] !== undefined) {
// result[userId] = `${customDepts[userId].toFixed(2)}`; // Format to 2 decimal places
// } else {
// throw new Error(`No fixed amount defined for user ID: ${userId}`);
// }
// });
// break;
default:
return {};
// throw new Error("Invalid split type.");
}
return result;
}
export function calculateDepts(
total: number,
users: string[],
payments: Array<Payment>
): Debt[] {
console.log("Payments: ", payments);
const share = total / users.length;
const balances: Record<string, number> = {};
// Initialize balances
users.forEach((userId) => {
const paid = payments.find((p) => p.userId === userId)?.amount || 0;
balances[userId] = paid - share;
});
// Separate creditors and debtors
const creditors = Object.entries(balances)
.filter(([_, balance]) => balance > 0)
.map(([userId, balance]) => ({ userId, balance }));
const debtors = Object.entries(balances)
.filter(([_, balance]) => balance < 0)
.map(([userId, balance]) => ({ userId, balance: -balance }));
const debts: Debt[] = [];
for (const debtor of debtors) {
let amountToPay = debtor.balance;
for (const creditor of creditors) {
if (amountToPay === 0) break;
if (creditor.balance === 0) continue;
const payment = Math.min(amountToPay, creditor.balance);
debts.push({
from: debtor.userId,
to: creditor.userId,
amount: parseFloat(payment.toFixed(2)), // for rounding safety
});
amountToPay -= payment;
creditor.balance -= payment;
}
}
return debts;
}

View File

@ -3,13 +3,10 @@ import { z } from "zod";
export const expenseSchema = z.object({
amount: z.number().min(0.01),
description: z.string().optional(),
friendId: z.string().optional(),
groupId: z.string().optional(),
});
export const expenseSplitSchema = z.object({
expenseId: z.string(),
groupId: z.string().optional(),
paidById: z.string().min(1),
owedById: z.string().min(1),
amount: z.number().min(1),

View File

@ -2,11 +2,13 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { expenses } from "@/server/db/schema";
import { expenseSchema } from "@/lib/validations/expense";
import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
export const expenseRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ expense: expenseSchema }))
.input(
z.object({ expense: expenseSchema, split: z.array(expenseSplitSchema) })
)
.mutation(async ({ ctx, input }) => {
const [expense] = await ctx.db.insert(expenses).values({
createdById: ctx.session.user.id,

View File

@ -18,14 +18,12 @@ export const friendRouter = createTRPCRouter({
userTwo: true,
},
});
const returnOne = friends.map((f) => ({
return friends.map((f) => ({
id: f.id,
status: f.status,
requestedBy: ctx.session.user.id === f.userOneId ? "me" : "them",
user: ctx.session.user.id === f.userOneId ? f.userTwo : f.userOne,
}));
console.log(returnOne[0]);
return returnOne;
}),
getPendingFriendRequests: protectedProcedure.query(
async ({ ctx }) =>

View File

@ -56,6 +56,7 @@ export const authConfig = {
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
callbacks: {
session: ({ session, user }) => ({
...session,

View File

@ -9,72 +9,72 @@
}
:root {
--radius: 0.625rem;
--radius: 0.5rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
}
.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--primary: oklch(0.646 0.222 41.116);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.646 0.222 41.116);
}
@theme inline {
@ -126,6 +126,11 @@
.max-w {
@apply max-w-md;
}
.text-stroke {
color: transparent !important; /* Make the fill transparent */
text-shadow: 1px var(--color-foreground); /* Stroke width and color */
}
}
.popover-content-width-same-as-its-trigger {