IIiinit
This commit is contained in:
commit
0cec39717e
23
.env.example
Normal file
23
.env.example
Normal 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
46
.gitignore
vendored
Normal 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
29
README.md
Normal 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
26
biome.jsonc
Normal 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
21
components.json
Normal 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
12
drizzle.config.ts
Normal 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
10
next.config.js
Normal 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
72
package.json
Normal 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
2976
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
35
src/app.config.ts
Normal file
35
src/app.config.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
19
src/app/(router)/add/page.tsx
Normal file
19
src/app/(router)/add/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(router)/expense/page.tsx
Normal file
5
src/app/(router)/expense/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Page() {
|
||||
return <div>Page</div>;
|
||||
}
|
||||
48
src/app/(router)/friend/page.tsx
Normal file
48
src/app/(router)/friend/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
src/app/(router)/group/page.tsx
Normal file
5
src/app/(router)/group/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Page() {
|
||||
return <div>Page</div>;
|
||||
}
|
||||
19
src/app/(router)/layout.tsx
Normal file
19
src/app/(router)/layout.tsx
Normal 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
13
src/app/(router)/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
src/app/_components/expense/expense-form.tsx
Normal file
103
src/app/_components/expense/expense-form.tsx
Normal 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;
|
||||
7
src/app/_components/expense/split-expense.tsx
Normal file
7
src/app/_components/expense/split-expense.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function SplitExpense() {
|
||||
return <div>SplitExpense</div>;
|
||||
}
|
||||
|
||||
export default SplitExpense;
|
||||
118
src/app/_components/friend/add-friend-drawer.tsx
Normal file
118
src/app/_components/friend/add-friend-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/_components/friend/friend-card.tsx
Normal file
19
src/app/_components/friend/friend-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/_components/friend/friend-request-button.tsx
Normal file
16
src/app/_components/friend/friend-request-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/_components/friend/friend-select.tsx
Normal file
29
src/app/_components/friend/friend-select.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
src/app/_components/friend/pending-request-button.tsx
Normal file
38
src/app/_components/friend/pending-request-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/server/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
34
src/app/api/trpc/[trpc]/route.ts
Normal file
34
src/app/api/trpc/[trpc]/route.ts
Normal 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
40
src/app/layout.tsx
Normal 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
25
src/components/avatar.tsx
Normal 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
143
src/components/combobox.tsx
Normal 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
17
src/components/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/mode-toggle.tsx
Normal file
40
src/components/mode-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/nav-link.tsx
Normal file
27
src/components/nav-link.tsx
Normal 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
19
src/components/navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/components/number-input.tsx
Normal file
190
src/components/number-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/section.tsx
Normal file
23
src/components/section.tsx
Normal 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;
|
||||
17
src/components/theme-provider.tsx
Normal file
17
src/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal 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 }
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal 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 };
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
177
src/components/ui/command.tsx
Normal file
177
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
135
src/components/ui/dialog.tsx
Normal file
135
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
132
src/components/ui/drawer.tsx
Normal file
132
src/components/ui/drawer.tsx
Normal 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,
|
||||
};
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal 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
167
src/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 };
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal 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 }
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal 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 }
|
||||
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal 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 }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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
52
src/env.js
Normal 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
6
src/index.d.ts
vendored
Normal 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
18
src/lib/utils.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
17
src/lib/validations/expense.ts
Normal file
17
src/lib/validations/expense.ts
Normal 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(),
|
||||
});
|
||||
10
src/lib/validations/group.ts
Normal file
10
src/lib/validations/group.ts
Normal 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(),
|
||||
});
|
||||
8
src/lib/validations/settlement.ts
Normal file
8
src/lib/validations/settlement.ts
Normal 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
25
src/server/api/root.ts
Normal 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);
|
||||
16
src/server/api/routers/expense.ts
Normal file
16
src/server/api/routers/expense.ts
Normal 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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
121
src/server/api/routers/friend.ts
Normal file
121
src/server/api/routers/friend.ts
Normal 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));
|
||||
}),
|
||||
});
|
||||
14
src/server/api/routers/user.ts
Normal file
14
src/server/api/routers/user.ts
Normal 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
133
src/server/api/trpc.ts
Normal 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
68
src/server/auth/config.ts
Normal 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
10
src/server/auth/index.ts
Normal 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
18
src/server/db/index.ts
Normal 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
356
src/server/db/schema.ts
Normal 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
134
src/styles/globals.css
Normal 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
25
src/trpc/query-client.ts
Normal 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
78
src/trpc/react.tsx
Normal 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
30
src/trpc/server.ts
Normal 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
83
start-database.sh
Executable 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
43
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user