diff --git a/components.json b/components.json index 0da679a..baef07e 100644 --- a/components.json +++ b/components.json @@ -6,7 +6,7 @@ "tailwind": { "config": "tailwind.config.ts", "css": "src/styles/globals.css", - "baseColor": "zinc", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, diff --git a/package.json b/package.json index da4b229..8b9f649 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,14 @@ "dependencies": { "@clerk/nextjs": "^6.5.1", "@clerk/themes": "^2.1.48", + "@hookform/resolvers": "^3.9.1", "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", "@sentry/nextjs": "^8", "@t3-oss/env-nextjs": "^0.10.1", "class-variance-authority": "^0.7.0", @@ -37,6 +42,7 @@ "posthog-js": "^1.194.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", "server-only": "^0.0.1", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9a3d71..7b4b7db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,15 +14,30 @@ importers: '@clerk/themes': specifier: ^2.1.48 version: 2.1.48 + '@hookform/resolvers': + specifier: ^3.9.1 + version: 3.9.1(react-hook-form@7.53.2(react@18.3.1)) '@radix-ui/react-avatar': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.0 + version: 2.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-navigation-menu': specifier: ^1.2.1 version: 1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.1.0 + version: 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^8 version: 8.41.0(@opentelemetry/core@1.28.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.28.0(@opentelemetry/api@1.9.0))(next@15.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.96.1(esbuild@0.19.12)) @@ -59,6 +74,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.53.2 + version: 7.53.2(react@18.3.1) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -532,6 +550,11 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@hookform/resolvers@3.9.1': + resolution: {integrity: sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==} + peerDependencies: + react-hook-form: ^7.0.0 + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -994,6 +1017,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.1.2': + resolution: {integrity: sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.0': resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} peerDependencies: @@ -1100,6 +1136,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.0': + resolution: {integrity: sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-navigation-menu@1.2.1': resolution: {integrity: sha512-egDo0yJD2IK8L17gC82vptkvW1jLeni1VuqCyzY727dSJdk5cDjINomouLoNk8RVF7g2aNIfENKWL4UzeU9c8Q==} peerDependencies: @@ -1161,6 +1210,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.1.1': + resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -1206,6 +1268,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.0': + resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.1.0': resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==} peerDependencies: @@ -2989,6 +3060,12 @@ packages: peerDependencies: react: ^18.3.1 + react-hook-form@7.53.2: + resolution: {integrity: sha512-YVel6fW5sOeedd1524pltpHX+jgU2u3DSDtXEaBORNdqiNrsX/nUI/iGXONegttg0mJVnfrIkiV0cmTU6Oo2xw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3834,6 +3911,10 @@ snapshots: '@eslint/js@8.57.1': {} + '@hookform/resolvers@3.9.1(react-hook-form@7.53.2(react@18.3.1))': + dependencies: + react-hook-form: 7.53.2(react@18.3.1) + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -4315,6 +4396,22 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-checkbox@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -4410,6 +4507,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-label@2.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-navigation-menu@1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -4468,6 +4574,21 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -4500,6 +4621,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-use-size@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -6431,6 +6559,10 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hook-form@7.53.2(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1): diff --git a/src/app/@modal/(.)timer/[id]/modal.tsx b/src/app/@modal/(.)timer/[id]/modal.tsx index 4481ebb..398d6a1 100644 --- a/src/app/@modal/(.)timer/[id]/modal.tsx +++ b/src/app/@modal/(.)timer/[id]/modal.tsx @@ -1,5 +1,5 @@ "use client"; -import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { useRouter } from "next/navigation"; export default function Modal({ children }: { children: React.ReactNode }) { diff --git a/src/app/@modal/(.)timer/[id]/page.tsx b/src/app/@modal/(.)timer/[id]/page.tsx index 652db6f..59a02c9 100644 --- a/src/app/@modal/(.)timer/[id]/page.tsx +++ b/src/app/@modal/(.)timer/[id]/page.tsx @@ -1,17 +1,26 @@ -import FullPageTimer from "~/app/_components/full-page-timer"; -import { getTimer } from "~/server/queries"; +import FullPageTimer from "@/app/_components/full-page-timer"; +import { getTimer } from "@/server/queries"; import Modal from "./modal"; +import { useAuth } from "@clerk/nextjs"; export default async function TimerModal({ params, }: { params: Promise<{ id: string }>; }) { + const user = useAuth(); + const timer = await getTimer((await params).id); + const owner = timer.userEmail === user.userId; + return ( - + ); } diff --git a/src/app/_analytics/providers.tsx b/src/app/_analytics/providers.tsx index c68c3ee..41abbc6 100644 --- a/src/app/_analytics/providers.tsx +++ b/src/app/_analytics/providers.tsx @@ -1,5 +1,7 @@ "use client"; +import { useEffect } from "react"; +import { useAuth, useUser } from "@clerk/nextjs"; import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; @@ -9,6 +11,28 @@ if (typeof window !== "undefined") { }); } +function PostHogAuthWrapper({ children }: { children: React.ReactNode }) { + const auth = useAuth(); + const userInfo = useUser(); + + useEffect(() => { + if (userInfo.user) { + posthog.identify(userInfo.user.id, { + email: userInfo.user.emailAddresses[0]?.emailAddress, + name: userInfo.user.fullName, + }); + } else if (!auth.isSignedIn) { + posthog.reset(); + } + }, [auth, userInfo]); + + return children; +} + export function CSPostHogProvider({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/_components/full-page-timer.tsx b/src/app/_components/full-page-timer.tsx index 72dd7ab..83ff21e 100644 --- a/src/app/_components/full-page-timer.tsx +++ b/src/app/_components/full-page-timer.tsx @@ -36,6 +36,7 @@ const calculateTimeSince = (start: Date | null) => { export default function Timer(props: { eventTime: Date | null; caption: string; + owner: boolean; }) { const [timeSince, setTimeSince] = useState( calculateTimeSince(props.eventTime), @@ -53,7 +54,7 @@ export default function Timer(props: { const formattedTime = formatTime(timeSince); return ( -
+

It has been

@@ -91,9 +92,10 @@ export default function Timer(props: {
SECONDS
-
+

{props.caption}

+
I am the owner
); } diff --git a/src/app/_components/navigation.tsx b/src/app/_components/navigation.tsx index 814d992..7adaa81 100644 --- a/src/app/_components/navigation.tsx +++ b/src/app/_components/navigation.tsx @@ -1,24 +1,47 @@ import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; +import Link from "next/link"; import { NavigationMenu, + NavigationMenuItem, NavigationMenuList, -} from "~/components/ui/navigation-menu"; + NavigationMenuLink, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; export function TopNav() { return ( ); } diff --git a/src/app/_components/timer.tsx b/src/app/_components/timer.tsx index 58b93e5..56bf910 100644 --- a/src/app/_components/timer.tsx +++ b/src/app/_components/timer.tsx @@ -7,8 +7,8 @@ import { CardDescription, CardHeader, CardTitle, -} from "~/components/ui/card"; -import { Avatar, AvatarImage } from "~/components/ui/avatar"; +} from "@/components/ui/card"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; const formatTime = (elapsed: number | null) => { if (elapsed === null) { diff --git a/src/app/api/sentry-example-api/route.ts b/src/app/api/sentry-example-api/route.ts deleted file mode 100644 index f486f3d..0000000 --- a/src/app/api/sentry-example-api/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from "next/server"; - -export const dynamic = "force-dynamic"; - -// A faulty API route to test Sentry's error monitoring -export function GET() { - throw new Error("Sentry Example API Route Error"); - return NextResponse.json({ data: "Testing Sentry Error..." }); -} diff --git a/src/app/api/timer/[id]/route.ts b/src/app/api/timer/[id]/route.ts index 40f08e9..12401e3 100644 --- a/src/app/api/timer/[id]/route.ts +++ b/src/app/api/timer/[id]/route.ts @@ -1,18 +1,14 @@ -import { updateTimerTime } from "~/server/queries"; +import { updateTimerTime } from "@/server/queries"; export async function POST( _: Request, { params }: { params: Promise<{ id: string }> }, ) { - const idAsNumber = parseInt((await params).id); - try { - const updated = await updateTimerTime(idAsNumber); + await updateTimerTime((await params).id); return Response.json({}, { status: 200 }); } catch (error: any) { - const typedError = error as Error; - if (error.message === "Timer not found") { return Response.json({}, { status: 404 }); } else { diff --git a/src/app/timer/[id]/page.tsx b/src/app/timer/[id]/page.tsx index 9fe0deb..1760422 100644 --- a/src/app/timer/[id]/page.tsx +++ b/src/app/timer/[id]/page.tsx @@ -1,5 +1,5 @@ -import FullPageTimer from "~/app/_components/full-page-timer"; -import { getTimer } from "~/server/queries"; +import FullPageTimer from "@/app/_components/full-page-timer"; +import { getTimer } from "@/server/queries"; export default async function TimerPage({ params, diff --git a/src/app/timer/create/create-timer.tsx b/src/app/timer/create/create-timer.tsx new file mode 100644 index 0000000..ecb27e8 --- /dev/null +++ b/src/app/timer/create/create-timer.tsx @@ -0,0 +1,7 @@ +"use server"; + +import { createTimer } from "@/server/queries"; + +export async function handleCreateTimer(caption: string, isPublic: boolean) { + return await createTimer(caption, isPublic); +} diff --git a/src/app/timer/create/page.tsx b/src/app/timer/create/page.tsx new file mode 100644 index 0000000..bd9e69c --- /dev/null +++ b/src/app/timer/create/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { handleCreateTimer } from "./create-timer"; + +const formSchema = z.object({ + caption: z.string().min(2, { + message: "Username must be at least 2 characters.", + }), + public: z.boolean(), +}); + +export default function ProfileForm() { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + caption: "", + public: false, + }, + }); + + async function onSubmit(values: z.infer) { + await handleCreateTimer(values.caption, values.public); + } + + return ( +
+
+ +
Create a new timer
+
+ ( + + Caption + + + + + )} + /> + ( + + + + + Public + + )} + /> + + + +
+ ); +} diff --git a/src/app/timer/page.tsx b/src/app/timer/page.tsx new file mode 100644 index 0000000..75a1c75 --- /dev/null +++ b/src/app/timer/page.tsx @@ -0,0 +1,48 @@ +import { createHash } from "crypto"; +import Link from "next/link"; +import Timer from "~/app/_components/timer"; +import { getAllMyTimers } from "~/server/queries"; + +const hashEmail = (email: string) => { + const hash = createHash("sha256") + .update(email.trim().toLowerCase()) + .digest("hex"); + + return hash; +}; + +export default async function HomePage() { + const timers = await getAllMyTimers(); + + const modulo = timers.length > 4 ? Math.floor(Math.random() * 4) + 2 : 1; + + return ( +
+ {timers.map((timer, idx) => + idx % modulo == 0 ? ( +
+ + + +
+ ) : ( +
+ + + +
+ ), + )} +
+ ); +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 05e961c..09cd14d 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; -import { cn } from "~/lib/utils" +import { cn } from "@/lib/utils"; const Avatar = React.forwardRef< React.ElementRef, @@ -13,12 +13,12 @@ const Avatar = React.forwardRef< ref={ref} className={cn( "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", - className + className, )} {...props} /> -)) -Avatar.displayName = AvatarPrimitive.Root.displayName +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< React.ElementRef, @@ -29,8 +29,8 @@ const AvatarImage = React.forwardRef< className={cn("aspect-square h-full w-full", className)} {...props} /> -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< React.ElementRef, @@ -40,11 +40,11 @@ const AvatarFallback = React.forwardRef< ref={ref} className={cn( "flex h-full w-full items-center justify-center rounded-full bg-muted", - className + className, )} {...props} /> -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback } +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..d09a695 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +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-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 874ce09..1cc0c12 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "~/lib/utils" +import { cn } from "@/lib/utils"; const Card = React.forwardRef< HTMLDivElement, @@ -10,12 +10,12 @@ const Card = React.forwardRef< ref={ref} className={cn( "rounded-xl border bg-card text-card-foreground shadow", - className + className, )} {...props} /> -)) -Card.displayName = "Card" +)); +Card.displayName = "Card"; const CardHeader = React.forwardRef< HTMLDivElement, @@ -26,8 +26,8 @@ const CardHeader = React.forwardRef< className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> -)) -CardHeader.displayName = "CardHeader" +)); +CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< HTMLDivElement, @@ -38,8 +38,8 @@ const CardTitle = React.forwardRef< className={cn("font-semibold leading-none tracking-tight", className)} {...props} /> -)) -CardTitle.displayName = "CardTitle" +)); +CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< HTMLDivElement, @@ -50,16 +50,16 @@ const CardDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -CardDescription.displayName = "CardDescription" +)); +CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
-)) -CardContent.displayName = "CardContent" +)); +CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< HTMLDivElement, @@ -70,7 +70,14 @@ const CardFooter = React.forwardRef< className={cn("flex items-center p-6 pt-0", className)} {...props} /> -)) -CardFooter.displayName = "CardFooter" +)); +CardFooter.displayName = "CardFooter"; -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..07680df --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "~/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index a75e1df..9e25b54 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,18 +1,18 @@ -"use client" +"use client"; -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; -import { cn } from "~/lib/utils" +import { cn } from "@/lib/utils"; -const Dialog = DialogPrimitive.Root +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal +const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, @@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef< -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, @@ -39,7 +39,7 @@ const DialogContent = React.forwardRef< ref={ref} className={cn( "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className + className, )} {...props} > @@ -50,8 +50,8 @@ const DialogContent = React.forwardRef< -)) -DialogContent.displayName = DialogPrimitive.Content.displayName +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, @@ -60,12 +60,12 @@ const DialogHeader = ({
-) -DialogHeader.displayName = "DialogHeader" +); +DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, @@ -74,12 +74,12 @@ const DialogFooter = ({
-) -DialogFooter.displayName = "DialogFooter" +); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, @@ -89,12 +89,12 @@ const DialogTitle = React.forwardRef< ref={ref} className={cn( "text-lg font-semibold leading-none tracking-tight", - className + className, )} {...props} /> -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, @@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, @@ -119,4 +119,4 @@ export { DialogFooter, DialogTitle, DialogDescription, -} +}; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..c3daa38 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} 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 = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + 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( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +