This commit is contained in:
shrt 2025-04-04 12:33:03 +02:00
commit 0cec39717e
73 changed files with 6825 additions and 0 deletions

23
.env.example Normal file
View File

@ -0,0 +1,23 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Next Auth
# You can generate a new secret on the command line with:
# npx auth secret
# https://next-auth.js.org/configuration/options#secret
AUTH_SECRET=""
# Next Auth Discord Provider
AUTH_DISCORD_ID=""
AUTH_DISCORD_SECRET=""
# Drizzle
DATABASE_URL="postgresql://postgres:password@localhost:5432/betterwise"

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# idea files
.idea

29
README.md Normal file
View File

@ -0,0 +1,29 @@
# Create T3 App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Drizzle](https://orm.drizzle.team)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

26
biome.jsonc Normal file
View File

@ -0,0 +1,26 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": { "ignoreUnknown": false, "ignore": [] },
"formatter": { "enabled": true },
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"nursery": {
"useSortedClasses": {
"level": "warn",
"fix": "safe",
"options": {
"functions": ["clsx", "cva", "cn"]
}
}
},
"recommended": true
}
}
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

12
drizzle.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { Config } from "drizzle-kit";
import { env } from "@/env";
export default {
schema: "./src/server/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: env.DATABASE_URL,
},
tablesFilter: ["betterwise_*"],
} satisfies Config;

10
next.config.js Normal file
View File

@ -0,0 +1,10 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {};
export default config;

72
package.json Normal file
View File

@ -0,0 +1,72 @@
{
"name": "betterwise",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "biome check .",
"check:unsafe": "biome check --write --unsafe .",
"check:write": "biome check --write .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev --turbo",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.2",
"@hookform/resolvers": "^5.0.1",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@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-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.41.0",
"lucide-react": "^0.487.0",
"next": "^15.2.3",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.6",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"server-only": "^0.0.1",
"sonner": "^2.0.3",
"superjson": "^2.2.1",
"tailwind-merge": "^3.1.0",
"tw-animate-css": "^1.2.5",
"use-debounce": "^10.0.4",
"vaul": "^1.1.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@tailwindcss/postcss": "^4.0.15",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"drizzle-kit": "^0.30.5",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2"
},
"ct3aMetadata": {
"initVersion": "7.39.2"
},
"packageManager": "pnpm@10.3.0"
}

2976
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

35
src/app.config.ts Normal file
View File

@ -0,0 +1,35 @@
type AppConfig = {
name: string;
navigator: Array<NavLink>;
};
export const appConfig: AppConfig = {
name: "Betterwise",
navigator: [
{
name: "Home",
href: "/",
icon: "Home",
},
{
name: "Expenses",
href: "/expense",
icon: "Wallet",
},
{
name: "Add Expense",
href: "/add",
icon: "Plus",
},
{
name: "Groups",
href: "/group",
icon: "Users",
},
{
name: "Friends",
href: "/friend",
icon: "User",
},
],
};

View File

@ -0,0 +1,19 @@
import ExpenseForm from "@/app/_components/expense/expense-form";
import Header from "@/components/header";
import Section from "@/components/section";
import { auth } from "@/server/auth";
import { api, HydrateClient } from "@/trpc/server";
import React from "react";
export default async function Page() {
const session = await auth();
if (session?.user) void api.friend.getAll.prefetch();
return (
<HydrateClient>
<Header text="Add Expense"></Header>
<Section>
<ExpenseForm />
</Section>
</HydrateClient>
);
}

View File

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

View File

@ -0,0 +1,48 @@
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 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 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");
return (
<>
<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>
</>
);
}

View File

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

View File

@ -0,0 +1,19 @@
import Navbar from "@/components/navbar";
import { auth } from "@/server/auth";
import { redirect } from "next/navigation";
import React from "react";
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
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>
<Navbar />
</div>
);
}

13
src/app/(router)/page.tsx Normal file
View File

@ -0,0 +1,13 @@
import { appConfig } from "@/app.config";
import Header from "@/components/header";
import { ModeToggle } from "@/components/mode-toggle";
export default async function Home() {
return (
<>
<Header text={appConfig.name}>
<ModeToggle />
</Header>
</>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import React from "react";
import type { Expense } from "@/server/db/schema";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} 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 }) {
const form = useForm<z.infer<typeof expenseSchema>>({
resolver: zodResolver(expenseSchema),
defaultValues: {
description: initialExpense?.description ?? "",
amount: initialExpense?.amount ?? 0,
groupId: initialExpense?.groupId ?? "",
},
});
function onSubmit(values: z.infer<typeof expenseSchema>) {
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 ">
<div className="flex items-center">
<Button type="submit" className="ml-auto">
{initialExpense?.id?.length ? "Update" : "Create"} Expense
</Button>
</div>
<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>
</TabsList>
<TabsContent value="expense" className="space-y-8 size-full">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="About this expense" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
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"
/>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="split">
<FormField
control={form.control}
name="friendId"
render={({ field }) => (
<FormItem>
<FormLabel>Select Friend</FormLabel>
<FormControl>
<FriendSelect
onSelect={field.onChange}
initialValue={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
</form>
</Form>
);
}
export default ExpenseForm;

View File

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

View File

@ -0,0 +1,118 @@
"use client";
import React from "react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { Input } from "@/components/ui/input";
import { useDebounce } from "use-debounce";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import type { PublicUser } from "next-auth";
import FriendCard from "./friend-card";
const SearchResultCard = ({
addFriend,
user,
pending,
}: {
addFriend: (userId: string) => void;
pending: boolean;
user: PublicUser;
}) => {
return (
<FriendCard user={user}>
<Button
size={"sm"}
className="ml-auto"
variant={"outline"}
disabled={pending}
onClick={() => {
if (!pending) addFriend(user.id!);
}}
>
{pending ? "Pending" : "Add Friend"}
</Button>
</FriendCard>
);
};
export default function AddFriendDrawer() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [searchValue] = useDebounce(value, 1000);
const {
data: searchResult,
isFetching,
refetch,
} = api.friend.search.useQuery(
{
search: searchValue,
},
{
enabled: !!searchValue,
}
);
const addFriend = api.friend.add.useMutation({
onSuccess() {
toast.success("Friend request sent!");
setOpen(false);
refetch();
},
});
const handleAddFriend = (userId: string) => {
addFriend.mutate({
userId,
});
};
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button size={"sm"}>Add Friend</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add a friend</DrawerTitle>
</DrawerHeader>
<div className="px-4 space-y-4">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
<ul>
{isFetching ? (
<Skeleton className="h-6 w-full" />
) : searchResult?.length ? (
searchResult.map((res) => (
<li key={res.user.id}>
<SearchResultCard addFriend={handleAddFriend} {...res} />
</li>
))
) : (
<p className="text-muted-foreground text-sm">
{searchValue.length
? "No results found"
: "Start typing to search for a friend"}
</p>
)}
</ul>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

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

View File

@ -0,0 +1,16 @@
import { Button } from "@/components/ui/button";
import React from "react";
export default function FriendRequestButton({}: {}) {
return (
<div>
<Button
variant={requestedBy === "me" ? "destructive" : "outline"}
className="ml-auto"
size="sm"
>
{requestedBy === "me" ? "Cancel" : "Accept"}
</Button>
</div>
);
}

View File

@ -0,0 +1,29 @@
"use client";
import Avatar from "@/components/avatar";
import { Combobox } from "@/components/combobox";
import { api } from "@/trpc/react";
import React from "react";
export default function FriendSelect({
onSelect,
initialValue,
}: {
onSelect: (value?: string) => void;
initialValue?: string;
}) {
const [friends] = api.friend.getAll.useSuspenseQuery();
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" />,
}))}
onSelect={onSelect}
initialValue={initialValue}
/>
);
}

View File

@ -0,0 +1,38 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { toast } from "sonner";
export default function PendingRequestButton({
friendshipId,
requestedBy,
}: {
friendshipId: string;
requestedBy: string;
}) {
const acceptRequest = api.friend.accept.useMutation({
onSuccess() {
toast.success("Friend request accepted!");
},
});
const cancleRequest = api.friend.cancel.useMutation({
onSuccess() {
toast.success("Friend request cancelled!");
},
});
return (
<Button
variant={requestedBy === "me" ? "destructive" : "outline"}
className="ml-auto"
size="sm"
onClick={() => {
if (requestedBy === "them") acceptRequest.mutate({ friendshipId });
else cancleRequest.mutate({ friendshipId });
}}
>
{requestedBy === "me" ? "Cancel" : "Accept"}
</Button>
);
}

View File

@ -0,0 +1,3 @@
import { handlers } from "@/server/auth";
export const { GET, POST } = handlers;

View File

@ -0,0 +1,34 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server";
import { env } from "@/env";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
export { handler as GET, handler as POST };

40
src/app/layout.tsx Normal file
View File

@ -0,0 +1,40 @@
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider";
export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<body>
<TRPCReactProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</TRPCReactProvider>
</body>
</html>
);
}

25
src/components/avatar.tsx Normal file
View File

@ -0,0 +1,25 @@
import React from "react";
import {
Avatar as AvatarComponent,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
function Avatar({
src,
fb,
className,
}: {
src?: string | null;
fb?: string | null;
className?: string;
}) {
return (
<AvatarComponent className={className}>
<AvatarImage src={src!} alt={fb!} />
<AvatarFallback>{fb?.slice(0, 2)?.toUpperCase()}</AvatarFallback>
</AvatarComponent>
);
}
export default Avatar;

143
src/components/combobox.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export type ComboboxProps = {
data: {
label: string;
value: string;
Icon?: LucideIcon;
children?: React.ReactNode;
}[];
onSelect: (value?: string) => void;
messageUi?: {
select?: string;
selectIcon?: LucideIcon;
empty?: string;
placeholder?: string;
};
initialValue?: string;
className?: string;
hideSearch?: boolean;
buttonProps?: ButtonProps;
};
export function Combobox({
data,
initialValue,
messageUi,
className,
hideSearch,
buttonProps,
onSelect,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(initialValue ?? "");
const selectedItem = data.find((item) => item.value === initialValue)!;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-[200px] justify-between shadow-none hover:bg-background",
className
)}
{...buttonProps}
>
<div
className={cn(
"flex items-center gap-2 text-muted-foreground",
selectedItem && "text-foreground"
)}
>
{selectedItem?.children}
{selectedItem?.Icon ? (
<selectedItem.Icon className="size-4" />
) : messageUi?.selectIcon ? (
<messageUi.selectIcon className="size-4" />
) : null}
<span>
{selectedItem?.label
? selectedItem.label
: messageUi?.select ?? "Select..."}
</span>
</div>
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="popover-content-width-same-as-its-trigger w-full p-0">
<Command
filter={(value, search) => {
if (!search.trim()) return 1; // Show all when no search input
const entry = data.find((item) => item.value === value); // Assuming dataset is an array of objects with id and name
if (!entry) return 0; // If no matching entry is found, exclude it
return entry.label.toLowerCase().includes(search.toLowerCase())
? 1
: 0;
}}
>
{!hideSearch && (
<CommandInput
placeholder={messageUi?.placeholder ?? "Search..."}
className="h-9"
/>
)}
<CommandList>
<CommandEmpty>{messageUi?.empty ?? "Nothing found."}</CommandEmpty>
<CommandGroup>
{data.map((item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const newValue = currentValue === value ? "" : currentValue;
setValue(newValue);
onSelect(newValue);
setOpen(false);
}}
>
<div className="flex items-center gap-2">
{item?.children}
{item?.Icon && <item.Icon className="ml-auto opacity-50" />}
<span>{item.label}</span>
</div>
<Check
className={cn(
"ml-auto",
value === item.value ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

17
src/components/header.tsx Normal file
View File

@ -0,0 +1,17 @@
import React from "react";
import { ModeToggle } from "@/components/mode-toggle";
export default function Header({
text,
children,
}: {
text: string;
children?: React.ReactNode;
}) {
return (
<div className="w-full p-4 border-b flex justify-between items-center">
<h2 className="font-bold text-xl ">{text}</h2>
{children}
</div>
);
}

View File

@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import * as LucideIcons from "lucide-react";
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;
return (
<Button
asChild
size={"icon"}
variant={"ghost"}
className={cn(active && "text-primary bg-accent/25")}
>
<Link href={href}>{IconComponent && <IconComponent />}</Link>
</Button>
);
}
export default NavLink;

19
src/components/navbar.tsx Normal file
View File

@ -0,0 +1,19 @@
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">
{appConfig.navigator.map((navItem, idx) => (
<li key={idx}>
<NavLink {...navItem} />
</li>
))}
</menu>
{/* <ModeToggle /> */}
</nav>
);
}

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

@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
import React from "react";
function Section({
children,
heading,
className,
}: {
children?: React.ReactNode;
heading?: string;
className?: string;
}) {
return (
<section className={cn("px-4 space-y-4", className)}>
{heading?.length ? (
<h2 className=" text-xl font-semibold">{heading}</h2>
) : null}
{children}
</section>
);
}
export default Section;

View File

@ -0,0 +1,17 @@
"use client";
import dynamic from "next/dynamic";
import * as React from "react";
const NextThemesProvider = dynamic(
() => import("next-themes").then((e) => e.ThemeProvider),
{
ssr: false,
}
);
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,177 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...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>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col max-w mx-auto",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,257 @@
"use client"
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"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

167
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<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)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground 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
)}
{...props}
/>
);
}
export { Input };

View File

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

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
);
}
export { Textarea };

52
src/env.js Normal file
View File

@ -0,0 +1,52 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
AUTH_DISCORD_ID: z.string(),
AUTH_DISCORD_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

6
src/index.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
type IconComponent = React.FC<React.SVGProps<SVGSVGElement>>;
type NavLink = {
name: string;
href: string;
icon?: string;
};

18
src/lib/utils.ts Normal file
View File

@ -0,0 +1,18 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function debounce<T extends (...args: any[]) => void>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}

View File

@ -0,0 +1,17 @@
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),
status: z.string(),
});

View File

@ -0,0 +1,10 @@
import { z } from "zod";
export const groupSchema = z.object({
name: z.string().optional(),
});
export const groupMemberSchema = z.object({
groupId: z.string().optional(),
userId: z.string().optional(),
});

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const settlementSchema = z.object({
playerId: z.string().optional(),
receiverId: z.string().optional(),
amount: z.number().min(1),
description: z.string().min(1),
});

25
src/server/api/root.ts Normal file
View File

@ -0,0 +1,25 @@
import { expenseRouter } from "@/server/api/routers/expense";
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { friendRouter } from "./routers/friend";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
expense: expenseRouter,
friend: friendRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
/**
* Create a server-side caller for the tRPC API.
* @example
* const trpc = createCaller(createContext);
* const res = await trpc.post.all();
* ^? Post[]
*/
export const createCaller = createCallerFactory(appRouter);

View File

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

View File

@ -0,0 +1,121 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { friendships, users } from "@/server/db/schema";
import { and, eq, ilike, inArray, not, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export const friendRouter = createTRPCRouter({
// queries
getAll: protectedProcedure.query(async ({ ctx }) => {
const friends = await ctx.db.query.friendships.findMany({
where: or(
eq(friendships.userOneId, ctx.session.user.id),
eq(friendships.userTwoId, ctx.session.user.id)
),
with: {
userOne: true,
userTwo: true,
},
});
const returnOne = 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 }) =>
await ctx.db.query.friendships.findMany({
where: and(
or(
eq(friendships.userOneId, ctx.session.user.id),
eq(friendships.userTwoId, ctx.session.user.id)
),
eq(friendships.status, "pending")
),
})
),
search: protectedProcedure
.input(z.object({ search: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const friendIds = await ctx.db.query.friendships.findMany({
where: and(
or(
eq(friendships.userOneId, userId),
eq(friendships.userTwoId, userId)
)
),
columns: {
userOneId: true,
userTwoId: true,
status: true,
},
});
const excludedIds = [
userId,
...friendIds
.filter((f) => f.status !== "pending")
.flatMap((f) => [f.userOneId === userId ? f.userTwoId : f.userOneId]),
];
const pendingIds = friendIds
.filter((f) => f.status === "pending")
.flatMap((f) => [f.userOneId === userId ? f.userTwoId : f.userOneId]);
const searchResult = await ctx.db.query.users.findMany({
where: and(
ilike(users.name, `%${input.search}%`),
not(inArray(users.id, excludedIds))
),
columns: {
image: true,
name: true,
id: true,
},
});
return searchResult.map((user) => {
const pending = pendingIds.includes(user.id);
return {
user,
pending,
};
});
}),
// mutations
add: protectedProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(friendships).values({
userOneId: ctx.session.user.id,
userTwoId: input.userId,
status: "pending",
});
revalidatePath("/friend");
}),
accept: protectedProcedure
.input(z.object({ friendshipId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(friendships)
.set({
status: "accepted",
})
.where(eq(friendships.id, input.friendshipId));
revalidatePath("/friend");
}),
cancel: protectedProcedure
.input(z.object({ friendshipId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db
.delete(friendships)
.where(eq(friendships.id, input.friendshipId));
}),
});

View File

@ -0,0 +1,14 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
export const userRouter = createTRPCRouter({
getAll: protectedProcedure.query(
async ({ ctx }) =>
await ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
image: true,
},
})
),
});

133
src/server/api/trpc.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1).
* 2. You want to create a new middleware or type of procedure (see Part 3).
*
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
* need to use are documented accordingly near the end.
*/
import { TRPCError, initTRPC } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API.
*
* These allow you to access things when processing a request, like the database, the session, etc.
*
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
* wrap this and provides the required context.
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
return {
db,
session,
...opts,
};
};
/**
* 2. INITIALIZATION
*
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* Create a server-side caller.
*
* @see https://trpc.io/docs/server/server-side-calls
*/
export const createCallerFactory = t.createCallerFactory;
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these a lot in the
* "/src/server/api/routers" directory.
*/
/**
* This is how you create new routers and sub-routers in your tRPC API.
*
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Middleware for timing procedure execution and adding an artificial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
/**
* Public (unauthenticated) procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);
/**
* Protected (authenticated) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
* the session is valid and guarantees `ctx.session.user` is not null.
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});

68
src/server/auth/config.ts Normal file
View File

@ -0,0 +1,68 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import type { DefaultSession, NextAuthConfig, User } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { db } from "@/server/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "@/server/db/schema";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
type PublicUser = Pick<User, "id" | "name" | "image">;
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
providers: [
DiscordProvider,
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
},
} satisfies NextAuthConfig;

10
src/server/auth/index.ts Normal file
View File

@ -0,0 +1,10 @@
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };

18
src/server/db/index.ts Normal file
View File

@ -0,0 +1,18 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "@/env";
import * as schema from "./schema";
/**
* Cache the database connection in development. This avoids creating a new connection on every HMR
* update.
*/
const globalForDb = globalThis as unknown as {
conn: postgres.Sql | undefined;
};
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
export const db = drizzle(conn, { schema });

356
src/server/db/schema.ts Normal file
View File

@ -0,0 +1,356 @@
import { relations, sql } from "drizzle-orm";
import { index, pgTableCreator, primaryKey } from "drizzle-orm/pg-core";
import type { User } from "next-auth";
import type { AdapterAccount } from "next-auth/adapters";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = pgTableCreator((name) => `betterwise_${name}`);
export const expenses = createTable(
"expense",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
groupId: d.uuid(),
friendId: d.uuid(),
createdById: d
.uuid()
.notNull()
.references(() => users.id),
amount: d.integer().notNull(),
currency: d.varchar({ length: 3 }).notNull().default("EUR"), // Default to EUR
description: d.varchar({ length: 512 }),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
(t) => [index("created_by_idx").on(t.createdById)]
);
export const expenseRelations = relations(expenses, ({ many, one }) => ({
splits: many(expenseSplits),
friend: one(users, {
fields: [expenses.friendId],
references: [users.id],
}),
group: one(groups, {
fields: [expenses.groupId],
references: [groups.id],
}),
createdBy: one(users, {
fields: [expenses.createdById],
references: [users.id],
}),
}));
export type Expense = typeof expenses.$inferSelect & {
splits?: Array<ExpenseSplit>;
group?: Group;
createdBy?: User;
};
export const expenseSplits = createTable(
"expense_split",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
expenseId: d
.uuid()
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
paidById: d
.uuid()
.notNull()
.references(() => users.id),
owedById: d
.uuid()
.notNull()
.references(() => users.id),
amount: d.integer().notNull(),
status: d.varchar({ length: 10 }).notNull().default("unpaid"), // 'unpaid' | 'settled'
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [index("expense_split_expense_idx").on(t.expenseId)]
);
export const expenseSplitRelations = relations(expenseSplits, ({ one }) => ({
expense: one(expenses, {
fields: [expenseSplits.expenseId],
references: [expenses.id],
}),
paidBy: one(users, {
fields: [expenseSplits.paidById],
references: [users.id],
}),
owedBy: one(users, {
fields: [expenseSplits.paidById],
references: [users.id],
}),
}));
export type ExpenseSplit = typeof expenseSplits.$inferSelect & {
expense?: Expense;
paidBy?: User;
owedBy?: User;
};
export const settlements = createTable(
"settlement",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
payerId: d
.uuid()
.notNull()
.references(() => users.id),
receiverId: d
.uuid()
.notNull()
.references(() => users.id),
amount: d.integer().notNull(),
currency: d.varchar({ length: 3 }).notNull().default("EUR"),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [
index("settlement_payer_idx").on(t.payerId),
index("settlement_receiver_idx").on(t.receiverId),
]
);
export const settlementRelations = relations(settlements, ({ one }) => ({
payer: one(users, {
fields: [settlements.payerId],
references: [users.id],
}),
receiver: one(users, {
fields: [settlements.receiverId],
references: [users.id],
}),
}));
export type Settlement = typeof settlements.$inferSelect & {
payer?: User;
receiver?: User;
};
// Groups Table
export const groups = createTable(
"group",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
name: d.varchar({ length: 255 }).notNull(),
createdById: d
.uuid()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [index("group_created_by_idx").on(t.createdById)]
);
export const groupRelations = relations(groups, ({ many, one }) => ({
createdBy: one(users, {
fields: [groups.createdById],
references: [users.id],
}),
members: many(groupMembers),
}));
export type Group = typeof groups.$inferSelect & {
createdBy?: User;
members?: Array<GroupMember>;
};
// Group Members Table (Tracks who is in a group)
export const groupMembers = createTable(
"group_member",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
groupId: d
.uuid()
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: d
.uuid()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
joinedAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [
index("group_member_group_idx").on(t.groupId),
index("group_member_user_idx").on(t.userId),
]
);
export const groupMemberRelations = relations(
groupMembers,
({ many, one }) => ({
group: one(groups, {
fields: [groupMembers.groupId],
references: [groups.id],
}),
user: one(users, {
fields: [groupMembers.userId],
references: [users.id],
}),
})
);
export type GroupMember = typeof groupMembers.$inferSelect & {
group?: Group;
user?: User;
};
// Friendships Table (Tracks friend relationships)
export const friendships = createTable(
"friendship",
(d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
userOneId: d
.uuid()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
userTwoId: d
.uuid()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
status: d
.varchar({ length: 20 })
.notNull()
.$type<"pending" | "accepted">()
.default("pending"),
createdAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [
index("friendship_user_one_idx").on(t.userOneId),
index("friendship_user_two_idx").on(t.userTwoId),
]
);
export const friendshipRelations = relations(friendships, ({ many, one }) => ({
userOne: one(users, {
fields: [friendships.userOneId],
references: [users.id],
}),
userTwo: one(users, {
fields: [friendships.userTwoId],
references: [users.id],
}),
}));
export type Friendship = typeof friendships.$inferSelect & {
userOne?: User;
userTwo?: User;
};
export const users = createTable("user", (d) => ({
id: d
.uuid()
.primaryKey()
.default(sql`gen_random_uuid()`),
name: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }).notNull(),
emailVerified: d
.timestamp({
mode: "date",
withTimezone: true,
})
.default(sql`CURRENT_TIMESTAMP`),
image: d.varchar({ length: 255 }),
}));
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
}));
export const accounts = createTable(
"account",
(d) => ({
userId: d
.uuid()
.notNull()
.references(() => users.id),
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.varchar({ length: 255 }).notNull(),
providerAccountId: d.varchar({ length: 255 }).notNull(),
refresh_token: d.text(),
access_token: d.text(),
expires_at: d.integer(),
token_type: d.varchar({ length: 255 }),
scope: d.varchar({ length: 255 }),
id_token: d.text(),
session_state: d.varchar({ length: 255 }),
}),
(t) => [
primaryKey({ columns: [t.provider, t.providerAccountId] }),
index("account_user_id_idx").on(t.userId),
]
);
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
export const sessions = createTable(
"session",
(d) => ({
sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
userId: d
.uuid()
.notNull()
.references(() => users.id),
expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
}),
(t) => [index("t_user_id_idx").on(t.userId)]
);
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const verificationTokens = createTable(
"verification_token",
(d) => ({
identifier: d.varchar({ length: 255 }).notNull(),
token: d.varchar({ length: 255 }).notNull(),
expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })]
);

134
src/styles/globals.css Normal file
View File

@ -0,0 +1,134 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--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);
--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);
--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);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--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);
--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);
--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);
--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-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-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
.max-w {
@apply max-w-md;
}
}
.popover-content-width-same-as-its-trigger {
width: var(--radix-popover-trigger-width);
max-height: var(--radix-popover-content-available-height);
}

25
src/trpc/query-client.ts Normal file
View File

@ -0,0 +1,25 @@
import {
QueryClient,
defaultShouldDehydrateQuery,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});

78
src/trpc/react.tsx Normal file
View File

@ -0,0 +1,78 @@
"use client";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchStreamLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import type { AppRouter } from "@/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
clientQueryClientSingleton ??= createQueryClient();
return clientQueryClientSingleton;
};
export const api = createTRPCReact<AppRouter>();
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
url: `${getBaseUrl()}/api/trpc`,
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}

30
src/trpc/server.ts Normal file
View File

@ -0,0 +1,30 @@
import "server-only";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { headers } from "next/headers";
import { cache } from "react";
import { type AppRouter, createCaller } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
import { createQueryClient } from "./query-client";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
});
});
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient,
);

83
start-database.sh Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Use this script to start a docker container for a local development database
# TO RUN ON WINDOWS:
# 1. Install WSL (Windows Subsystem for Linux) - https://learn.microsoft.com/en-us/windows/wsl/install
# 2. Install Docker Desktop or Podman Deskop
# - Docker Desktop for Windows - https://docs.docker.com/docker-for-windows/install/
# - Podman Desktop - https://podman.io/getting-started/installation
# 3. Open WSL - `wsl`
# 4. Run this script - `./start-database.sh`
# On Linux and macOS you can run this script directly - `./start-database.sh`
# import env variables from .env
set -a
source .env
DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}')
DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'\/' '{print $1}')
DB_NAME=$(echo "$DATABASE_URL" | awk -F'/' '{print $4}')
DB_CONTAINER_NAME="$DB_NAME-postgres"
if ! [ -x "$(command -v docker)" ] && ! [ -x "$(command -v podman)" ]; then
echo -e "Docker or Podman is not installed. Please install docker or podman and try again.\nDocker install guide: https://docs.docker.com/engine/install/\nPodman install guide: https://podman.io/getting-started/installation"
exit 1
fi
# determine which docker command to use
if [ -x "$(command -v docker)" ]; then
DOCKER_CMD="docker"
elif [ -x "$(command -v podman)" ]; then
DOCKER_CMD="podman"
fi
if ! $DOCKER_CMD info > /dev/null 2>&1; then
echo "$DOCKER_CMD daemon is not running. Please start $DOCKER_CMD and try again."
exit 1
fi
if command -v nc >/dev/null 2>&1; then
if nc -z localhost "$DB_PORT" 2>/dev/null; then
echo "Port $DB_PORT is already in use."
exit 1
fi
else
echo "Warning: Unable to check if port $DB_PORT is already in use (netcat not installed)"
read -p "Do you want to continue anyway? [y/N]: " -r REPLY
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Aborting."
exit 1
fi
fi
if [ "$($DOCKER_CMD ps -q -f name=$DB_CONTAINER_NAME)" ]; then
echo "Database container '$DB_CONTAINER_NAME' already running"
exit 0
fi
if [ "$($DOCKER_CMD ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
$DOCKER_CMD start "$DB_CONTAINER_NAME"
echo "Existing database container '$DB_CONTAINER_NAME' started"
exit 0
fi
if [ "$DB_PASSWORD" = "password" ]; then
echo "You are using the default database password"
read -p "Should we generate a random password for you? [y/N]: " -r REPLY
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Please change the default password in the .env file and try again"
exit 1
fi
# Generate a random URL-safe password
DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
sed -i '' "s#:password@#:$DB_PASSWORD@#" .env
fi
$DOCKER_CMD run -d \
--name $DB_CONTAINER_NAME \
-e POSTGRES_USER="postgres" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB="$DB_NAME" \
-p "$DB_PORT":5432 \
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"

43
tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
".eslintrc.cjs",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}