Added clerk for authentication

This commit is contained in:
Ceferino Patino 2024-12-01 19:52:16 -06:00
commit b5fdb9ccae
Signed by: c4patino
SSH key fingerprint: SHA256:Wu+cU1t+7zVT9wzew/4meNmeulo66NzMqc22MdDBgXI
29 changed files with 848 additions and 134 deletions

View file

@ -6,7 +6,7 @@
"tailwind": { "tailwind": {
"config": "tailwind.config.ts", "config": "tailwind.config.ts",
"css": "src/styles/globals.css", "css": "src/styles/globals.css",
"baseColor": "zinc", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },

View file

@ -22,9 +22,14 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^6.5.1", "@clerk/nextjs": "^6.5.1",
"@clerk/themes": "^2.1.48", "@clerk/themes": "^2.1.48",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-avatar": "^1.1.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-dialog": "^1.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.1", "@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", "@sentry/nextjs": "^8",
"@t3-oss/env-nextjs": "^0.10.1", "@t3-oss/env-nextjs": "^0.10.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@ -37,6 +42,7 @@
"posthog-js": "^1.194.1", "posthog-js": "^1.194.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

132
pnpm-lock.yaml generated
View file

@ -14,15 +14,30 @@ importers:
'@clerk/themes': '@clerk/themes':
specifier: ^2.1.48 specifier: ^2.1.48
version: 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': '@radix-ui/react-avatar':
specifier: ^1.1.1 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) 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': '@radix-ui/react-dialog':
specifier: ^1.1.2 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) 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': '@radix-ui/react-navigation-menu':
specifier: ^1.2.1 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) 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': '@sentry/nextjs':
specifier: ^8 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)) 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: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@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: server-only:
specifier: ^0.0.1 specifier: ^0.0.1
version: 0.0.1 version: 0.0.1
@ -532,6 +550,11 @@ packages:
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 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': '@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@ -994,6 +1017,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-collection@1.1.0':
resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==}
peerDependencies: peerDependencies:
@ -1100,6 +1136,19 @@ packages:
'@types/react': '@types/react':
optional: true 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': '@radix-ui/react-navigation-menu@1.2.1':
resolution: {integrity: sha512-egDo0yJD2IK8L17gC82vptkvW1jLeni1VuqCyzY727dSJdk5cDjINomouLoNk8RVF7g2aNIfENKWL4UzeU9c8Q==} resolution: {integrity: sha512-egDo0yJD2IK8L17gC82vptkvW1jLeni1VuqCyzY727dSJdk5cDjINomouLoNk8RVF7g2aNIfENKWL4UzeU9c8Q==}
peerDependencies: peerDependencies:
@ -1161,6 +1210,19 @@ packages:
'@types/react': '@types/react':
optional: true 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': '@radix-ui/react-use-callback-ref@1.1.0':
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
peerDependencies: peerDependencies:
@ -1206,6 +1268,15 @@ packages:
'@types/react': '@types/react':
optional: true 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': '@radix-ui/react-visually-hidden@1.1.0':
resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==} resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==}
peerDependencies: peerDependencies:
@ -2989,6 +3060,12 @@ packages:
peerDependencies: peerDependencies:
react: ^18.3.1 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: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -3834,6 +3911,10 @@ snapshots:
'@eslint/js@8.57.1': {} '@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': '@humanwhocodes/config-array@0.13.0':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
@ -4315,6 +4396,22 @@ snapshots:
'@types/react': 18.3.12 '@types/react': 18.3.12
'@types/react-dom': 18.3.1 '@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)': '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@ -4410,6 +4507,15 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.12 '@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)': '@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: dependencies:
'@radix-ui/primitive': 1.1.0 '@radix-ui/primitive': 1.1.0
@ -4468,6 +4574,21 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.12 '@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)': '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies: dependencies:
react: 18.3.1 react: 18.3.1
@ -4500,6 +4621,13 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.12 '@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)': '@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: 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) '@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 react: 18.3.1
scheduler: 0.23.2 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-is@16.13.1: {}
react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1): react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1):

View file

@ -1,5 +1,5 @@
"use client"; "use client";
import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: React.ReactNode }) { export default function Modal({ children }: { children: React.ReactNode }) {

View file

@ -1,17 +1,26 @@
import FullPageTimer from "~/app/_components/full-page-timer"; import FullPageTimer from "@/app/_components/full-page-timer";
import { getTimer } from "~/server/queries"; import { getTimer } from "@/server/queries";
import Modal from "./modal"; import Modal from "./modal";
import { useAuth } from "@clerk/nextjs";
export default async function TimerModal({ export default async function TimerModal({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const user = useAuth();
const timer = await getTimer((await params).id); const timer = await getTimer((await params).id);
const owner = timer.userEmail === user.userId;
return ( return (
<Modal> <Modal>
<FullPageTimer eventTime={timer.updatedAt} caption={timer.caption} /> <FullPageTimer
eventTime={timer.updatedAt}
caption={timer.caption}
owner={owner}
/>
</Modal> </Modal>
); );
} }

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import { useEffect } from "react";
import { useAuth, useUser } from "@clerk/nextjs";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react"; 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 }) { export function CSPostHogProvider({ children }: { children: React.ReactNode }) {
return <PostHogProvider client={posthog}>{children}</PostHogProvider>; return (
<PostHogProvider client={posthog}>
<PostHogAuthWrapper>{children}</PostHogAuthWrapper>
</PostHogProvider>
);
} }

View file

@ -36,6 +36,7 @@ const calculateTimeSince = (start: Date | null) => {
export default function Timer(props: { export default function Timer(props: {
eventTime: Date | null; eventTime: Date | null;
caption: string; caption: string;
owner: boolean;
}) { }) {
const [timeSince, setTimeSince] = useState( const [timeSince, setTimeSince] = useState(
calculateTimeSince(props.eventTime), calculateTimeSince(props.eventTime),
@ -53,7 +54,7 @@ export default function Timer(props: {
const formattedTime = formatTime(timeSince); const formattedTime = formatTime(timeSince);
return ( return (
<div className="flex h-full w-full flex-col justify-center"> <div className="flex h-full w-full flex-col justify-center text-center">
<div className="text-center text-2xl text-gray-300"> <div className="text-center text-2xl text-gray-300">
<p>It has been</p> <p>It has been</p>
</div> </div>
@ -91,9 +92,10 @@ export default function Timer(props: {
<div className="text-md px-1 text-gray-400">SECONDS</div> <div className="text-md px-1 text-gray-400">SECONDS</div>
</div> </div>
</div> </div>
<div className="text-center text-2xl text-gray-300"> <div className="text-2xl text-gray-300">
<p>{props.caption}</p> <p>{props.caption}</p>
</div> </div>
<div className="my-8">I am the owner</div>
</div> </div>
); );
} }

View file

@ -1,24 +1,47 @@
import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
import Link from "next/link";
import { import {
NavigationMenu, NavigationMenu,
NavigationMenuItem,
NavigationMenuList, NavigationMenuList,
} from "~/components/ui/navigation-menu"; NavigationMenuLink,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
export function TopNav() { export function TopNav() {
return ( return (
<nav className="flex w-full items-center justify-between border-b px-8 py-4 text-xl font-semibold"> <nav className="flex w-full items-center justify-between border-b px-8 py-4 text-xl font-semibold">
<NavigationMenu className="gap-4">
<Link href="/">
<div>Days Since</div> <div>Days Since</div>
</Link>
<NavigationMenu> <SignedIn>
<NavigationMenuList> <NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink
className={navigationMenuTriggerStyle()}
href="/timer"
>
My Timers
</NavigationMenuLink>
<NavigationMenuLink
className={navigationMenuTriggerStyle()}
href="/timer/create"
>
Create
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</SignedIn>
</NavigationMenu>
<SignedOut> <SignedOut>
<SignInButton mode="modal" /> <SignInButton mode="modal" />
</SignedOut> </SignedOut>
<SignedIn> <SignedIn>
<UserButton showName /> <UserButton />
</SignedIn> </SignedIn>
</NavigationMenuList>
</NavigationMenu>
</nav> </nav>
); );
} }

View file

@ -7,8 +7,8 @@ import {
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "@/components/ui/card";
import { Avatar, AvatarImage } from "~/components/ui/avatar"; import { Avatar, AvatarImage } from "@/components/ui/avatar";
const formatTime = (elapsed: number | null) => { const formatTime = (elapsed: number | null) => {
if (elapsed === null) { if (elapsed === null) {

View file

@ -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..." });
}

View file

@ -1,18 +1,14 @@
import { updateTimerTime } from "~/server/queries"; import { updateTimerTime } from "@/server/queries";
export async function POST( export async function POST(
_: Request, _: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const idAsNumber = parseInt((await params).id);
try { try {
const updated = await updateTimerTime(idAsNumber); await updateTimerTime((await params).id);
return Response.json({}, { status: 200 }); return Response.json({}, { status: 200 });
} catch (error: any) { } catch (error: any) {
const typedError = error as Error;
if (error.message === "Timer not found") { if (error.message === "Timer not found") {
return Response.json({}, { status: 404 }); return Response.json({}, { status: 404 });
} else { } else {

View file

@ -1,5 +1,5 @@
import FullPageTimer from "~/app/_components/full-page-timer"; import FullPageTimer from "@/app/_components/full-page-timer";
import { getTimer } from "~/server/queries"; import { getTimer } from "@/server/queries";
export default async function TimerPage({ export default async function TimerPage({
params, params,

View file

@ -0,0 +1,7 @@
"use server";
import { createTimer } from "@/server/queries";
export async function handleCreateTimer(caption: string, isPublic: boolean) {
return await createTimer(caption, isPublic);
}

View file

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
caption: "",
public: false,
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
await handleCreateTimer(values.caption, values.public);
}
return (
<div className="flex size-full items-center justify-center">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-3/12 flex-col gap-4 border p-12"
>
<div className="text-2xl">Create a new timer</div>
<hr className="mb-4" />
<FormField
control={form.control}
name="caption"
render={({ field }) => (
<FormItem>
<FormLabel className="text-lg">Caption</FormLabel>
<FormControl>
<Input placeholder="Caption" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="public"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
className="mr-2"
/>
</FormControl>
<FormLabel className="text-lg">Public</FormLabel>
</FormItem>
)}
/>
<Button type="submit" className="my-4">
Submit
</Button>
</form>
</Form>
</div>
);
}

48
src/app/timer/page.tsx Normal file
View file

@ -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 (
<div className={"m-4 grid grid-cols-4 gap-4"}>
{timers.map((timer, idx) =>
idx % modulo == 0 ? (
<div key={timer.id} className="row-span-2">
<Link href={`/timer/${timer.id}`}>
<Timer
eventTime={timer.updatedAt}
caption={timer.caption}
email={timer.userEmail}
emailHash={hashEmail(timer.userEmail)}
/>
</Link>
</div>
) : (
<div key={timer.id} className="row-span-1">
<Link href={`/timer/${timer.id}`}>
<Timer
eventTime={timer.updatedAt}
caption={timer.caption}
email={timer.userEmail}
emailHash={hashEmail(timer.userEmail)}
/>
</Link>
</div>
),
)}
</div>
);
}

View file

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "~/lib/utils" import { cn } from "@/lib/utils";
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,
@ -13,12 +13,12 @@ const Avatar = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Avatar.displayName = AvatarPrimitive.Root.displayName Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef< const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>, React.ElementRef<typeof AvatarPrimitive.Image>,
@ -29,8 +29,8 @@ const AvatarImage = React.forwardRef<
className={cn("aspect-square h-full w-full", className)} className={cn("aspect-square h-full w-full", className)}
{...props} {...props}
/> />
)) ));
AvatarImage.displayName = AvatarPrimitive.Image.displayName AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef< const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ElementRef<typeof AvatarPrimitive.Fallback>,
@ -40,11 +40,11 @@ const AvatarFallback = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted", "flex h-full w-full items-center justify-center rounded-full bg-muted",
className className,
)} )}
{...props} {...props}
/> />
)) ));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };

View file

@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "~/lib/utils" import { cn } from "@/lib/utils";
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -10,12 +10,12 @@ const Card = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-xl border bg-card text-card-foreground shadow", "rounded-xl border bg-card text-card-foreground shadow",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Card.displayName = "Card" Card.displayName = "Card";
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)) ));
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
className={cn("font-semibold leading-none tracking-tight", className)} className={cn("font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) ));
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)) ));
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)) ));
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -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<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -1,18 +1,18 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react" 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< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -22,12 +22,12 @@ const DialogOverlay = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 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/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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", "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} {...props}
> >
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@ -60,12 +60,12 @@ const DialogHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
@ -74,12 +74,12 @@ const DialogFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -89,12 +89,12 @@ const DialogTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@ -119,4 +119,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };

179
src/components/ui/form.tsx Normal file
View file

@ -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<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, formState } = useFormContext();
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,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View file

@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View file

@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -3,7 +3,7 @@ import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef< const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>, React.ElementRef<typeof NavigationMenuPrimitive.Root>,

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "~/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs))
} }

View file

@ -1,33 +1,77 @@
import "server-only"; import "server-only";
import { eq } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { timers } from "./db/schema"; import { timers } from "./db/schema";
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { auth, currentUser } from "@clerk/nextjs/server";
type NewTimer = typeof timers.$inferInsert;
export async function getTimer(id: string) { export async function getTimer(id: string) {
const user = await auth();
const timer = await db.query.timers.findFirst({ const timer = await db.query.timers.findFirst({
where: (model, { and, eq }) => where: (model, { and, eq, or }) =>
and(eq(model.id, id), eq(model.public, true)), and(
eq(model.id, id),
or(eq(model.public, true), eq(model.userId, user.userId ?? "")),
),
}); });
if (!timer) notFound(); if (!timer) notFound();
if (!timer.public) notFound();
return timer; return timer;
} }
export async function getAllTimers() { export async function getAllTimers() {
const user = await auth();
const timers = db.query.timers const timers = db.query.timers
.findMany({ .findMany({
where: (model, { eq }) => eq(model.public, true), where: (model) =>
or(eq(model.public, true), eq(model.userId, user.userId ?? "")),
}) })
.execute(); .execute();
return timers; return timers;
} }
export async function getAllMyTimers() {
const user = await auth();
if (!user.userId) throw new Error("Not authenticated");
const timers = db.query.timers
.findMany({
where: (model, { eq }) => eq(model.userId, user.userId),
})
.execute();
return timers;
}
export async function createTimer(caption: string, isPublic: boolean) {
const user = await auth();
if (!user.userId) throw new Error("Not authenticated");
const userInfo = await currentUser();
const insertTimer = (timer: NewTimer) => {
return db.insert(timers).values(timer).returning({ id: timers.id });
};
const [timer] = await insertTimer({
userId: user.userId,
userEmail: userInfo?.emailAddresses[0]?.emailAddress ?? "",
caption,
public: isPublic,
});
if (!timer) throw new Error("Timer not created");
redirect(`/timer/${timer.id}`);
}
export async function updateTimerTime(id: string) { export async function updateTimerTime(id: string) {
const updated = await db const updated = await db
.update(timers) .update(timers)

View file

@ -4,57 +4,55 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 10% 3.9%; --foreground: 0 0% 3.9%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 240 10% 3.9%; --card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%; --popover-foreground: 0 0% 3.9%;
--primary: 240 5.9% 10%; --primary: 0 0% 9%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%; --secondary: 0 0% 96.1%;
--secondary-foreground: 240 5.9% 10%; --secondary-foreground: 0 0% 9%;
--muted: 240 4.8% 95.9%; --muted: 0 0% 96.1%;
--muted-foreground: 240 3.8% 46.1%; --muted-foreground: 0 0% 45.1%;
--accent: 240 4.8% 95.9%; --accent: 0 0% 96.1%;
--accent-foreground: 240 5.9% 10%; --accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%; --border: 0 0% 89.8%;
--input: 240 5.9% 90%; --input: 0 0% 89.8%;
--ring: 240 10% 3.9%; --ring: 0 0% 3.9%;
--chart-1: 12 76% 61%; --chart-1: 12 76% 61%;
--chart-2: 173 58% 39%; --chart-2: 173 58% 39%;
--chart-3: 197 37% 24%; --chart-3: 197 37% 24%;
--chart-4: 43 74% 66%; --chart-4: 43 74% 66%;
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
--radius: 0.5rem --radius: 0.5rem}
}
.dark { .dark {
--background: 240 10% 3.9%; --background: 0 0% 3.9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 0 0% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%; --popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%; --primary-foreground: 0 0% 9%;
--secondary: 240 3.7% 15.9%; --secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%; --muted: 0 0% 14.9%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 0 0% 63.9%;
--accent: 240 3.7% 15.9%; --accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%; --border: 0 0% 14.9%;
--input: 240 3.7% 15.9%; --input: 0 0% 14.9%;
--ring: 240 4.9% 83.9%; --ring: 0 0% 83.1%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55% --chart-5: 340 75% 55%}
}
} }
@layer base { @layer base {
* { * {

View file

@ -26,6 +26,7 @@
/* Path Aliases */ /* Path Aliases */
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"],
"~/*": ["./src/*"] "~/*": ["./src/*"]
} }
}, },