Move to monorepo for React Native!

This commit is contained in:
2025-09-12 16:44:21 -05:00
parent 4cafc11422
commit b1eae564be
144 changed files with 2535 additions and 311 deletions

8
apps/next/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
node_modules
.next
dist
coverage
*.log
docker-compose*.yml
host/

47
apps/next/.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Hosting
/host/convex/docker/data
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Ignored for the template, you probably want to remove it:
package-lock.json

21
apps/next/components.json Normal file
View File

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

18
apps/next/env.example Normal file
View File

@@ -0,0 +1,18 @@
### Server Variables ###
# Convex
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
NEXT_PUBLIC_CONVEX_URL=
SETUP_SCRIPT_RAN=
# Sentry
SENTRY_AUTH_TOKEN=
### Client Variables ###
# Next # Default Values:
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
# Sentry # Default Values
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL=
NEXT_PUBLIC_SENTRY_ORG=
NEXT_PUBLIC_SENTRY_PROJECT_NAME=

View File

@@ -0,0 +1,24 @@
import { FlatCompat } from '@eslint/eslintrc';
import tseslint from 'typescript-eslint';
import { baseConfig } from '../../eslint.config.base.js';
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{ ignores: ['.next'] },
...compat.extends('next/core-web-vitals'),
baseConfig,
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
);

57
apps/next/next.config.js Normal file
View File

@@ -0,0 +1,57 @@
import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
import { withPlausibleProxy } from 'next-plausible';
/** @type {import("next").NextConfig} */
const nextConfig = withPlausibleProxy({
customDomain: 'https://plausible.gbrown.org',
})({
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
serverExternalPackages: ['require-in-the-middle'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
});
const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(nextConfig, sentryConfig);

60
apps/next/package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "techtracker-next",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbo",
"dev:slow": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix"
},
"dependencies": {
"@convex-dev/auth": "^0.0.81",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@sentry/nextjs": "^10.11.0",
"@t3-oss/env-nextjs": "^0.13.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.27.0",
"eslint-plugin-prettier": "^5.5.4",
"lucide-react": "^0.542.0",
"next": "^15.5.3",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-image-crop": "^11.0.10",
"require-in-the-middle": "^7.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"typescript-eslint": "^8.43.0",
"vaul": "^1.1.2",
"zod": "^4.1.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^20.19.13",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"dotenv": "^16.6.1",
"eslint-config-next": "^15.5.3",
"npm-run-all": "^4.1.5",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.3.8"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M414.4 376.5 200 379.6l-1.4-256.7 103.5-15.2 108.8-1.5z" style="fill:#fff"/><path d="M502.6 103.7c-3.3-3.3-7.8-3.3-7.8-3.3s-95.5 5.4-144.9 6.5c-10.8.2-21.6.5-32.3.6V203c-4.5-2.1-9-4.3-13.5-6.4 0-29.6-.1-88.9-.1-88.9-23.6.3-72.7-1.8-72.7-1.8s-115.2-5.8-127.7-6.9c-8-.5-18.3-1.7-31.8 1.2-7.1 1.5-27.3 6-43.8 21.9C-8.7 154.8.7 206.7 1.9 214.5c1.4 9.5 5.6 36 25.8 59 37.3 45.7 117.6 44.6 117.6 44.6s9.9 23.5 24.9 45.2c20.4 27 41.3 48 61.7 50.5 51.3 0 153.9-.1 153.9-.1s9.8.1 23-8.4c11.4-6.9 21.6-19.1 21.6-19.1s10.5-11.2 25.2-36.9c4.5-7.9 8.2-15.6 11.5-22.8 0 0 45-95.4 45-188.2-1-28-7.9-33-9.5-34.6M97.7 269.9c-21.1-6.9-30.1-15.2-30.1-15.2S52 243.8 44.2 222.3c-13.4-36-1.1-58-1.1-58s6.8-18.3 31.4-24.4c11.2-3 25.2-2.5 25.2-2.5s5.8 48.4 12.8 76.7c5.9 23.8 20.2 63.3 20.2 63.3s-21.3-2.6-35-7.5m289.4-4.5c-5.2 12.6-44.8 92.1-44.8 92.1s-5 11.8-16 12.5c-4.7.3-8.4-1-8.4-1s-.2-.1-4.3-1.7l-92-44.8s-8.9-4.6-10.4-12.7c-1.8-6.6 2.2-14.7 2.2-14.7l44.2-91.1s3.9-7.9 9.9-10.6c.5-.2 1.9-.8 3.7-1.2 6.6-1.7 14.7 2.3 14.7 2.3l18.4 8.9c-3.7 7.6-7.5 15.2-11.2 22.9-5.5-.1-10.5 2.9-13.1 7.7-2.8 5.1-2.2 11.5 1.5 16.1-6.6 13.8-13.3 27.5-19.9 41.1-6.7.1-12.5 4.7-14.1 11.2-1.5 6.5 1.6 13.3 7.4 16.3 6.3 3.3 14.3 1.5 18.5-4.4 4.2-5.8 3.5-13.8-1.5-18.8l19.5-40c1.2.1 3 .2 5-.4 3.3-.7 5.8-2.9 5.8-2.9 3.4 1.5 7 3.1 10.8 5 3.9 2 7.6 4 10.9 5.9.7.4 1.5.9 2.3 1.5 1.3 1.1 2.8 2.5 3.8 4.5 1.5 4.5-1.5 12.1-1.5 12.1-1.9 6.2-15 33.1-15 33.1-6.6-.2-12.5 4.1-14.4 10.2-2.1 6.6.9 14.1 7.2 17.3 6.4 3.3 14.2 1.4 18.3-4.3 4.1-5.5 3.7-13.3-.9-18.4l4.6-9.2c4.1-8.5 11-24.8 11-24.8.7-1.4 4.6-8.4 2.2-17.3-2-9.3-10.3-13.6-10.3-13.6-9.9-6.4-23.8-12.4-23.8-12.4s0-3.3-.9-5.8-2.3-4.2-3.2-5.1c3.6-7.6 7.4-15.1 11-22.6l61.8 29.9s10.3 4.6 12.5 13.2c1.5 6-.4 11.4-1.5 14" style="fill:#609926"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg513"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="Fullscreen.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview515"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="8.4359982"
inkscape:cx="69.108597"
inkscape:cy="37.458519"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="3832"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs510" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 5.8615386,0 H 0 V 5.8615386 H 0.97692256 V 0.97692327 H 5.8615386 Z"
id="path396-6"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 0,6.8384619 V 12.7 H 5.8615386 V 11.723076 H 0.97692256 V 6.8384619 Z"
id="path396-52"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 6.8384613,12.7 H 12.7 V 6.8384619 H 11.723078 V 11.723076 H 6.8384613 Z"
id="path396-4"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 12.7,5.8615386 V 0 H 6.8384613 V 0.97692327 H 11.723078 V 5.8615386 Z"
id="path396-0"
sodipodi:nodetypes="ccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg513"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="ExitFullscreen.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview515"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="8.4359982"
inkscape:cx="69.108597"
inkscape:cy="37.458519"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="3832"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs510" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 0,5.8620045 H 5.8615381 L 5.8621526,0 H 4.8849607 L 4.8846152,4.8851478 H 0 Z"
id="path190"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 6.8384615,8.6556325e-4 V 5.8620045 H 12.7 V 4.8851478 H 7.815384 V 8.6556325e-4 Z"
id="path396"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 12.7,6.8388612 H 6.8384615 V 12.7 H 7.815384 V 7.8157173 H 12.7 Z"
id="path396-5"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 5.8615381,12.7 V 6.8388612 H 0 V 7.8157173 H 4.8846152 V 12.7 Z"
id="path396-1"
sodipodi:nodetypes="ccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,9 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import './src/env.js';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
debug: false,
});

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
};
};
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default ProfileLayout;

View File

@@ -0,0 +1,28 @@
'use server';
import { preloadQuery } from 'convex/nextjs';
import { api } from '~/convex/_generated/api';
import { Card, Separator } from '@/components/ui';
import {
AvatarUpload,
ProfileHeader,
ResetPasswordForm,
SignOutForm,
UserInfoForm,
} from '@/components/layout/profile';
const Profile = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser);
return (
<Card className='max-w-xl min-w-xs sm:min-w-md mx-auto mb-8'>
<ProfileHeader preloadedUser={preloadedUser} />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator />
<UserInfoForm preloadedUser={preloadedUser} />
<Separator />
<ResetPasswordForm />
<Separator />
<SignOutForm />
</Card>
);
};
export default Profile;

View File

@@ -0,0 +1,326 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link';
import { useAuthActions } from '@convex-dev/auth/react';
import { useRouter } from 'next/navigation';
import { ConvexError } from 'convex/values';
import { useState } from 'react';
import {
Card,
CardContent,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui';
import { toast } from 'sonner';
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
const signInFormSchema = z.object({
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect password. Does not meet requirements.',
}),
});
const signUpFormSchema = z
.object({
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const SignIn = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
const [loading, setLoading] = useState(false);
const router = useRouter();
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
defaultValues: { email: '', password: '' },
});
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData);
signInForm.reset();
router.push('/');
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
setLoading(false);
}
};
const handleSignUp = async (values: z.infer<typeof signUpFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
formData.append('name', values.name);
setLoading(true);
try {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData);
signUpForm.reset();
router.push('/');
} catch (error) {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
setLoading(false);
}
};
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
<Tabs
defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='items-center'
>
<TabsList className='py-6'>
<TabsTrigger
value='signIn'
className='p-6 text-2xl font-bold cursor-pointer'
>
Sign In
</TabsTrigger>
<TabsTrigger
value='signUp'
className='p-6 text-2xl font-bold cursor-pointer'
>
Sign Up
</TabsTrigger>
</TabsList>
<TabsContent value='signIn'>
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
<CardContent>
<Form {...signInForm}>
<form
onSubmit={signInForm.handleSubmit(handleSignIn)}
className='flex flex-col space-y-8'
>
<FormField
control={signInForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signInForm.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel className='text-xl'>Password</FormLabel>
<Link href='/forgot-password'>
Forgot Password?
</Link>
</div>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing in...'
className='text-lg font-semibold w-2/3 mx-auto'
>
Sign In
</SubmitButton>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='signUp'>
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
<CardContent>
<Form {...signUpForm}>
<form
onSubmit={signUpForm.handleSubmit(handleSignUp)}
className='flex flex-col space-y-8'
>
<FormField
control={signUpForm.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Name</FormLabel>
<FormControl>
<Input
type='text'
placeholder='Full Name'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='text-lg font-semibold w-2/3 mx-auto'
>
Sign Up
</SubmitButton>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</Card>
</div>
);
};
export default SignIn;

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Status Table',
};
};
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignInLayout;

View File

@@ -0,0 +1,18 @@
'use server';
import { preloadQuery } from 'convex/nextjs';
import { api } from '~/convex/_generated/api';
import { StatusTable } from '@/components/layout/status';
const StatusTablePage = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser);
const preloadedStatuses = await preloadQuery(api.statuses.getCurrentForAll);
return (
<main>
<StatusTable
preloadedUser={preloadedUser}
preloadedStatuses={preloadedStatuses}
/>
</main>
);
};
export default StatusTablePage;

View File

@@ -0,0 +1,75 @@
'use client';
import type { Metadata } from 'next';
import NextError from 'next/error';
import { Geist, Geist_Mono } from 'next/font/google';
import '@/styles/globals.css';
import {
ConvexClientProvider,
ThemeProvider,
TVModeProvider,
} from '@/components/providers';
import * as Sentry from '@sentry/nextjs';
import { generateMetadata } from '@/lib/metadata';
import PlausibleProvider from 'next-plausible';
import Header from '@/components/layout/header';
import { useEffect } from 'react';
import { Button, Toaster } from '@/components/ui';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
const metadata: Metadata = generateMetadata();
metadata.title = `Error | Tech Tracker`;
export { metadata };
type GlobalErrorProps = {
error: Error & { digest?: string };
reset?: () => void;
};
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<ConvexClientProvider>
<PlausibleProvider
domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org'
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<TVModeProvider>
<Header />
<main className='min-h-[90vh] flex flex-col items-center'>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try Again</Button>
)}
<Toaster />
</main>
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexClientProvider>
);
};
export default GlobalError;

View File

@@ -0,0 +1,59 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import '@/styles/globals.css';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import {
ConvexClientProvider,
ThemeProvider,
TVModeProvider,
} from '@/components/providers';
import PlausibleProvider from 'next-plausible';
import { generateMetadata } from '@/lib/metadata';
import { Toaster } from '@/components/ui';
import Header from '@/components/layout/header';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = generateMetadata();
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
domain='techtracker.gbrown.org'
customDomain='https://plausible.gbrown.org'
>
<html lang='en'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<TVModeProvider>
<Header />
{children}
<Toaster />
</TVModeProvider>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexAuthNextjsServerProvider>
);
}

View File

@@ -0,0 +1,18 @@
'use server';
import { preloadQuery } from 'convex/nextjs';
import { api } from '~/convex/_generated/api';
import { StatusList } from '@/components/layout/status/list';
const Home = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser);
const preloadedStatuses = await preloadQuery(api.statuses.getCurrentForAll);
return (
<main>
<StatusList
preloadedUser={preloadedUser}
preloadedStatuses={preloadedStatuses}
/>
</main>
);
};
export default Home;

View File

@@ -0,0 +1,86 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
BasedAvatar,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui';
import { useConvexAuth, useQuery } from 'convex/react';
import { useTVMode } from '@/components/providers';
import { useAuthActions } from '@convex-dev/auth/react';
import { api } from '~/convex/_generated/api';
export const AvatarDropdown = () => {
const router = useRouter();
const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions();
const { tvMode, toggleTVMode } = useTVMode();
const user = useQuery(api.auth.getUser);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image } : 'skip',
);
if (isLoading)
return <BasedAvatar className='animate-pulse lg:h-10 lg:w-10' />;
if (!isAuthenticated) return <div />;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<BasedAvatar
src={currentImageUrl}
fullName={user?.name}
className='lg:h-10 lg:w-10'
fallbackProps={{ className: 'text-xl font-semibold' }}
userIconProps={{ size: 32 }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
{(user?.name ?? user?.email) && (
<>
<DropdownMenuLabel className='font-bold text-center'>
{user.name?.trim() ?? user.email?.trim()}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<button
onClick={toggleTVMode}
className='w-full justify-center cursor-pointer'
>
{tvMode ? 'Normal Mode' : 'TV Mode'}
</button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href='/profile'
className='w-full justify-center cursor-pointer'
>
Edit Profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className='h-[2px]' />
<DropdownMenuItem asChild>
<button
onClick={() =>
void signOut().then(() => {
router.push('/signin');
})
}
className='w-full justify-center cursor-pointer'
>
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,20 @@
'use client';
import { ThemeToggle, type ThemeToggleProps } from '@/components/providers';
import { AvatarDropdown } from './AvatarDropdown';
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
return (
<div className='flex flex-row items-center'>
<ThemeToggle
size={1.2}
buttonProps={{
variant: 'secondary',
size: 'sm',
className: 'mr-4 py-5',
...themeToggleProps?.buttonProps,
}}
/>
<AvatarDropdown />
</div>
);
};

View File

@@ -0,0 +1,72 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useTVMode } from '@/components/providers';
import { cn } from '@/lib/utils';
import { type ComponentProps } from 'react';
import { Controls } from './controls';
const Header = (headerProps: ComponentProps<'header'>) => {
const { tvMode } = useTVMode();
if (tvMode)
return (
<header
{...headerProps}
className={cn(
'w-full px-4 md:px-6 lg:px-20 my-8',
headerProps?.className,
)}
>
<div className='flex-1 flex justify-end mt-5'>
<Controls />
</div>
</header>
);
return (
<header
{...headerProps}
className={cn(
'w-full px-4 md:px-6 lg:px-20 my-8',
headerProps?.className,
)}
>
<div className='flex items-center justify-between'>
{/* Left spacer for perfect centering */}
<div className='flex flex-1 justify-start' />
{/* Centered logo and title */}
<div className='flex-shrink-0'>
<Link
href='/'
scroll={false}
className='flex flex-row items-center justify-center px-4'
>
<Image
src='/favicon.png'
alt='Tech Tracker Logo'
width={100}
height={100}
className='w-10 md:w-[120px]'
/>
<h1
className='title-text text-base md:text-4xl lg:text-8xl
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Tech Tracker
</h1>
</Link>
</div>
{/* Right-aligned controls */}
<div className='flex-1 flex justify-end'>
<Controls />
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,238 @@
'use client';
import Image from 'next/image';
import { type ChangeEvent, useRef, useState } from 'react';
import {
type Preloaded,
usePreloadedQuery,
useMutation,
useQuery,
} from 'convex/react';
import { api } from '~/convex/_generated/api';
import {
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
ImageCropReset,
Input,
} from '@/components/ui';
import { toast } from 'sonner';
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
import { type Id } from '~/convex/_generated/dataModel';
type AvatarUploadProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
const dataUrlToBlob = async (
dataUrl: string,
): Promise<{ blob: Blob; type: string }> => {
const re = /^data:([^;,]+)[;,]/;
const m = re.exec(dataUrl);
const type = m?.[1] ?? 'image/png';
const res = await fetch(dataUrl);
const blob = await res.blob();
return { blob, type };
};
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const user = usePreloadedQuery(preloadedUser);
const [isUploading, setIsUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [croppedImage, setCroppedImage] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUserImage = useMutation(api.auth.updateUserImage);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image } : 'skip',
);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file.');
if (inputRef.current) inputRef.current.value = '';
return;
}
setSelectedFile(file);
setCroppedImage(null);
};
const handleReset = () => {
setSelectedFile(null);
setCroppedImage(null);
if (inputRef.current) inputRef.current.value = '';
};
const handleSave = async () => {
if (!croppedImage) {
toast.error('Please apply a crop first.');
return;
}
setIsUploading(true);
try {
const { blob, type } = await dataUrlToBlob(croppedImage);
const postUrl = await generateUploadUrl();
const result = await fetch(postUrl, {
method: 'POST',
headers: { 'Content-Type': type },
body: blob,
});
if (!result.ok) {
const msg = await result.text().catch(() => 'Upload failed.');
throw new Error(msg);
}
const uploadResponse = (await result.json()) as {
storageId: Id<'_storage'>;
};
await updateUserImage({ storageId: uploadResponse.storageId });
toast.success('Profile picture updated.');
handleReset();
} catch (error) {
console.error('Upload failed:', error);
toast.error('Upload failed. Please try again.');
} finally {
setIsUploading(false);
}
};
return (
<CardContent>
<div className='flex flex-col items-center gap-4'>
{/* Current avatar + trigger (hidden when cropping) */}
{!selectedFile && (
<div
className='relative group cursor-pointer'
onClick={() => inputRef.current?.click()}
>
<BasedAvatar
src={currentImageUrl ?? undefined}
fullName={user?.name}
className='h-32 w-32'
fallbackProps={{ className: 'text-4xl font-semibold' }}
userIconProps={{ size: 100 }}
/>
<div
className='absolute inset-0 rounded-full bg-black/0
group-hover:bg-black/50 transition-all flex items-center
justify-center'
>
<Upload
className='text-white opacity-0 group-hover:opacity-100
transition-opacity'
size={24}
/>
</div>
<div
className='absolute inset-1 transition-all flex items-end
justify-end'
>
<Pencil
className='text-white opacity-100 group-hover:opacity-0
transition-opacity'
size={24}
/>
</div>
</div>
)}
{/* File input (hidden) */}
<Input
ref={inputRef}
id='avatar-upload'
type='file'
accept='image/*'
className='hidden'
onChange={handleFileChange}
disabled={isUploading}
/>
{/* Crop UI */}
{selectedFile && !croppedImage && (
<div className='flex flex-col items-center gap-3'>
<ImageCrop
aspect={1}
circularCrop
file={selectedFile}
maxImageSize={3 * 1024 * 1024} // 3MB guard
onCrop={setCroppedImage}
>
<ImageCropContent className='max-w-sm' />
<div className='flex items-center gap-2'>
<ImageCropApply />
<ImageCropReset />
<Button
onClick={handleReset}
size='icon'
type='button'
variant='ghost'
>
<XIcon className='size-4' />
</Button>
</div>
</ImageCrop>
</div>
)}
{/* Cropped preview + actions */}
{croppedImage && (
<div className='flex flex-col items-center gap-3'>
<Image
alt='Cropped preview'
className='overflow-hidden rounded-full'
height={128}
src={croppedImage}
unoptimized
width={128}
/>
<div className='flex items-center gap-2'>
<Button
onClick={handleSave}
disabled={isUploading}
className='px-6'
>
{isUploading ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
Saving...
</span>
) : (
'Save Avatar'
)}
</Button>
<Button
onClick={handleReset}
size='icon'
type='button'
variant='ghost'
>
<XIcon className='size-4' />
</Button>
</div>
</div>
)}
{/* Uploading indicator */}
{isUploading && !croppedImage && (
<div className='flex items-center text-sm text-gray-500 mt-2'>
<Loader2 className='h-4 w-4 mr-2 animate-spin' />
Uploading...
</div>
)}
</div>
</CardContent>
);
};

View File

@@ -0,0 +1,24 @@
'use client';
import { type Preloaded, usePreloadedQuery } from 'convex/react';
import { type api } from '~/convex/_generated/api';
import { CardHeader, CardTitle, CardDescription } from '@/components/ui';
type ProfileCardProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => {
const user = usePreloadedQuery(preloadedUser);
return (
<CardHeader className='pb-2'>
<CardTitle className='text-2xl'>
{user?.name ?? user?.email ?? 'Your Profile'}
</CardTitle>
<CardDescription>
Manage your personal information &amp; how it appears to others.
</CardDescription>
</CardHeader>
);
};
export { ProfileHeader };

View File

@@ -0,0 +1,5 @@
export { AvatarUpload } from './avatar-upload';
export { ProfileHeader } from './header';
export { ResetPasswordForm } from './reset-password';
export { SignOutForm } from './sign-out';
export { UserInfoForm } from './user-info';

View File

@@ -0,0 +1,174 @@
'use client';
import { useState } from 'react';
import { useAction } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
} from '@/components/ui';
import { toast } from 'sonner';
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types';
const formSchema = z
.object({
currentPassword: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect current password. Does not meet requirements.',
}),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: 'New password must be at least 8 characters.',
})
.max(PASSWORD_MAX, {
message: 'New password must be less than 100 characters.',
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string(),
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: 'New password must be different from current password.',
path: ['newPassword'],
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
export const ResetPasswordForm = () => {
const [loading, setLoading] = useState(false);
const changePassword = useAction(api.auth.updateUserPassword);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
setLoading(true);
try {
const result = await changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
});
if (result?.success) {
form.reset();
toast.success('Password updated successfully.');
}
} catch (error) {
console.error('Error updating password:', error);
toast.error('Error updating password.');
} finally {
setLoading(false);
}
};
return (
<>
<CardHeader className='pb-5'>
<CardTitle className='text-2xl'>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-6'
>
<FormField
control={form.control}
name='currentPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormDescription>
Enter your current password.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormDescription>
Enter your new password. Must be at least 8 characters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormDescription>
Please re-enter your new password to confirm.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-center'>
<SubmitButton
className='lg:w-1/3 w-2/3 text-[1.0rem]'
disabled={loading}
pendingText='Updating Password...'
>
Update Password
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};

View File

@@ -0,0 +1,22 @@
'use client';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { CardHeader, SubmitButton } from '@/components/ui';
export const SignOutForm = () => {
const { signOut } = useAuthActions();
const router = useRouter();
return (
<div className='flex justify-center'>
<SubmitButton
className='lg:w-2/3 w-5/6
text-[1.0rem] font-semibold cursor-pointer
hover:bg-red-700/60 dark:hover:bg-red-300/80'
onClick={() => void signOut().then(() => router.push('/signin'))}
>
Sign Out
</SubmitButton>
</div>
);
};

View File

@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
} from '@/components/ui';
import { toast } from 'sonner';
const formSchema = z.object({
name: z
.string()
.trim()
.min(5, {
message: 'Full name is required & must be at least 5 characters.',
})
.max(50, {
message: 'Full name must be less than 50 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
});
type UserInfoFormProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false);
const updateUserName = useMutation(api.auth.updateUserName);
const updateUserEmail = useMutation(api.auth.updateUserEmail);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: user?.name ?? '',
email: user?.email ?? '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const ops: Promise<unknown>[] = [];
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name }));
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email }));
if (ops.length === 0) return;
setLoading(true);
try {
await Promise.all(ops);
form.reset({ name, email });
toast.success('Profile updated successfully.');
} catch (error) {
console.error(error);
toast.error('Error updating profile.');
} finally {
setLoading(false);
}
};
return (
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your email address associated with your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-center'>
<SubmitButton disabled={loading} pendingText='Saving...'>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
);
};

View File

@@ -0,0 +1,195 @@
'use client';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '~/convex/_generated/api';
import type { Id } from '~/convex/_generated/dataModel';
import { formatDate, formatTime } from '@/lib/utils';
import {
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
Pagination,
PaginationContent,
PaginationNext,
PaginationPrevious,
ScrollArea,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Button,
BasedAvatar,
} from '@/components/ui';
type StatusHistoryProps = {
user?: (typeof api.statuses.getCurrentForAll._returnType)[0]['user'];
};
const PAGE_SIZE = 25;
export const StatusHistory = ({ user }: StatusHistoryProps) => {
const [pageIndex, setPageIndex] = useState(0);
// cursor for page N is the continueCursor returned from page N-1
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const args = useMemo(() => {
return {
userId: user?.id,
paginationOpts: {
numItems: PAGE_SIZE,
cursor: cursors[pageIndex] ?? null,
},
};
}, [user?.id, cursors, pageIndex]);
const data = useQuery(api.statuses.listHistory, args);
// Track loading
const isLoading = data === undefined;
// When a page loads, cache its "next" cursor if we don't have it yet
useEffect(() => {
if (!data) return;
const nextIndex = pageIndex + 1;
setCursors((prev) => {
const copy = [...prev];
if (copy[nextIndex] === undefined) copy[nextIndex] = data.continueCursor;
return copy;
});
}, [data, pageIndex]);
const canPrev = pageIndex > 0;
const canNext = !!data && data.continueCursor !== null;
const handlePrev = () => {
if (!canPrev) return;
setPageIndex((p) => Math.max(0, p - 1));
};
const handleNext = () => {
if (!canNext) return;
setPageIndex((p) => p + 1);
};
const rows = data?.page ?? [];
return (
<DrawerContent className='max-w-4xl mx-auto'>
<DrawerHeader>
<DrawerTitle>
<div className='flex flex-row items-center justify-center py-4'>
{user ? (
<BasedAvatar
src={user?.imageUrl}
fullName={user?.name}
className='w-8 h-8 md:w-12 md:h-12'
/>
) : (
<Image
src='/favicon.png'
alt='Tech Tracker Logo'
width={32}
height={32}
className='w-8 h-8 md:w-12 md:h-12'
/>
)}
<h1 className='text-lg md:text-2xl lg:text-4xl font-bold pl-2 md:pl-4'>
{user ? `${user.name ?? 'Technician'}'s History` : 'All History'}
</h1>
</div>
</DrawerTitle>
</DrawerHeader>
<div className='px-4'>
<ScrollArea className='h-96 w-full px-6'>
{isLoading ? (
<div className='flex justify-center items-center h-32'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-primary' />
</div>
) : rows.length === 0 ? (
<div className='flex justify-center items-center h-32'>
<p className='text-muted-foreground'>No history found</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className='font-semibold'>Name</TableHead>
<TableHead className='font-semibold'>Status</TableHead>
<TableHead className='font-semibold'>Updated By</TableHead>
<TableHead className='font-semibold text-right'>
Date &amp; Time
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, idx) => (
<TableRow key={`${r.status?.id ?? 'no-status'}-${idx}`}>
<TableCell className='font-medium'>
{r.user.name ?? 'Technician'}
</TableCell>
<TableCell className='max-w-xs'>
<div className='truncate' title={r.status?.message ?? ''}>
{r.status?.message ?? 'No status'}
</div>
</TableCell>
<TableCell className='text-sm text-muted-foreground'>
{r.status?.updatedBy?.name ?? ''}
</TableCell>
<TableCell className='text-right text-sm'>
{r.status
? `${formatTime(r.status.updatedAt)} · ${formatDate(
r.status.updatedAt,
)}`
: '--:-- · --/--'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</ScrollArea>
</div>
<DrawerFooter>
<Pagination>
<PaginationContent>
<PaginationPrevious
href='#'
onClick={(e) => {
e.preventDefault();
handlePrev();
}}
aria-disabled={!canPrev}
className={!canPrev ? 'pointer-events-none opacity-50' : ''}
/>
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<span>Page</span>
<span className='font-bold text-foreground'>{pageIndex + 1}</span>
</div>
<PaginationNext
href='#'
onClick={(e) => {
e.preventDefault();
handleNext();
}}
aria-disabled={!canNext}
className={!canNext ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationContent>
</Pagination>
<DrawerClose asChild>
<Button variant='outline' className='mt-4'>
Close
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
);
};

View File

@@ -0,0 +1,3 @@
export { StatusHistory } from './history';
export { StatusList } from './list';
export { StatusTable } from './table';

View File

@@ -0,0 +1,222 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { formatDate, formatTime } from '@/lib/utils';
import {
Pagination,
PaginationContent,
PaginationNext,
PaginationPrevious,
ScrollArea,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui';
const PAGE_SIZE = 25;
export const HistoryTable = () => {
const [pageIndex, setPageIndex] = useState(0);
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const args = useMemo(() => {
return {
paginationOpts: {
numItems: PAGE_SIZE,
cursor: cursors[pageIndex] ?? null,
},
};
}, [cursors, pageIndex]);
const data = useQuery(api.statuses.listHistory, args);
const isLoading = data === undefined;
useEffect(() => {
if (!data) return;
const nextIndex = pageIndex + 1;
setCursors((prev) => {
const copy = [...prev];
if (copy[nextIndex] === undefined) {
copy[nextIndex] = data.continueCursor;
}
return copy;
});
}, [data, pageIndex]);
const canPrev = pageIndex > 0;
const canNext = !!data && data.continueCursor !== null;
const handlePrev = () => {
if (!canPrev) return;
setPageIndex((p) => Math.max(0, p - 1));
};
const handleNext = () => {
if (!canNext) return;
setPageIndex((p) => p + 1);
};
const rows = data?.page ?? [];
return (
<div className='w-full px-4 sm:px-6'>
{/* Mobile: card list */}
<div className='md:hidden'>
<ScrollArea className='max-h-[70vh] w-full'>
{isLoading ? (
<div className='flex justify-center items-center h-32'>
<div
className='animate-spin rounded-full h-8 w-8
border-b-2 border-primary'
/>
</div>
) : rows.length === 0 ? (
<div className='flex justify-center items-center h-32'>
<p className='text-muted-foreground'>No history found</p>
</div>
) : (
<div className='space-y-2 pb-2'>
{rows.map((r, idx) => {
const key = `${r.status?.id ?? 'no-status'}-${idx}`;
const name = r.user.name ?? 'Technician';
const msg = r.status?.message ?? 'No status';
const updatedBy = r.status?.updatedBy?.name ?? null;
const stamp = r.status
? `${formatTime(r.status.updatedAt)} · ${formatDate(
r.status.updatedAt,
)}`
: '--:-- · --/--';
return (
<div key={key} className='rounded-lg border p-3'>
<div className='flex items-start justify-between'>
<div className='min-w-0'>
<div className='font-medium truncate'>{name}</div>
<div
className='text-sm text-muted-foreground
mt-0.5 line-clamp-2 break-words'
title={msg}
>
{msg}
</div>
</div>
{updatedBy && (
<span
className='ml-3 shrink-0 rounded
bg-muted px-2 py-0.5 text-xs
text-foreground'
title={`Updated by ${updatedBy}`}
>
{updatedBy}
</span>
)}
</div>
<div
className='mt-2 flex items-center gap-2
text-xs text-muted-foreground'
>
<span>{stamp}</span>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
{/* Desktop: original table */}
<div className='hidden md:block'>
<ScrollArea className='h-[600px] w-full px-4'>
{isLoading ? (
<div className='flex justify-center items-center h-32'>
<div
className='animate-spin rounded-full h-8 w-8
border-b-2 border-primary'
/>
</div>
) : rows.length === 0 ? (
<div className='flex justify-center items-center h-32'>
<p className='text-muted-foreground'>No history found</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className='font-semibold'>Name</TableHead>
<TableHead className='font-semibold'>Status</TableHead>
<TableHead className='font-semibold'>Updated By</TableHead>
<TableHead className='font-semibold text-right'>
Date &amp; Time
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, idx) => (
<TableRow key={`${r.status?.id ?? 'no-status'}-${idx}`}>
<TableCell className='font-medium'>
{r.user.name ?? 'Technician'}
</TableCell>
<TableCell className='max-w-xs'>
<div className='truncate' title={r.status?.message ?? ''}>
{r.status?.message ?? 'No status'}
</div>
</TableCell>
<TableCell className='text-sm text-muted-foreground'>
{r.status?.updatedBy?.name ?? ''}
</TableCell>
<TableCell className='text-right text-sm'>
{r.status
? `${formatTime(r.status.updatedAt)} · ${formatDate(
r.status.updatedAt,
)}`
: '--:-- · --/--'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</ScrollArea>
</div>
{/* Pagination */}
<div className='mt-3 sm:mt-4'>
<Pagination>
<PaginationContent>
<PaginationPrevious
href='#'
onClick={(e) => {
e.preventDefault();
handlePrev();
}}
aria-disabled={!canPrev}
className={!canPrev ? 'pointer-events-none opacity-50' : ''}
/>
<div
className='flex items-center gap-2 text-sm
text-muted-foreground'
>
<span>Page</span>
<span className='font-bold text-foreground'>{pageIndex + 1}</span>
</div>
<PaginationNext
href='#'
onClick={(e) => {
e.preventDefault();
handleNext();
}}
aria-disabled={!canNext}
className={!canNext ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationContent>
</Pagination>
</div>
</div>
);
};

View File

@@ -0,0 +1,496 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { type Id } from '~/convex/_generated/dataModel';
import { useTVMode } from '@/components/providers';
import {
BasedAvatar,
Button,
Card,
CardContent,
Drawer,
DrawerTrigger,
Input,
SubmitButton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui';
import { toast } from 'sonner';
import { ccn, formatTime, formatDate } from '@/lib/utils';
import {
Activity,
Clock,
Calendar,
CheckCircle2,
History,
Users,
Zap,
} from 'lucide-react';
import { StatusHistory } from '@/components/layout/status';
import { HistoryTable } from '@/components/layout/status/list/history';
type StatusListProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
preloadedStatuses: Preloaded<typeof api.statuses.getCurrentForAll>;
};
export const StatusList = ({
preloadedUser,
preloadedStatuses,
}: StatusListProps) => {
const user = usePreloadedQuery(preloadedUser);
const statuses = usePreloadedQuery(preloadedStatuses);
const { tvMode } = useTVMode();
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [updatingStatus, setUpdatingStatus] = useState(false);
const [animatingIds, setAnimatingIds] = useState<Set<string>>(new Set());
const [previousStatuses, setPreviousStatuses] = useState(statuses);
const bulkCreate = useMutation(api.statuses.bulkCreate);
useEffect(() => {
const newAnimatingIds = new Set<string>();
statuses.forEach((curr) => {
const previous = previousStatuses.find((p) => p.user.id === curr.user.id);
if (previous?.status?.updatedAt !== curr.status?.updatedAt) {
newAnimatingIds.add(curr.user.id);
}
});
if (newAnimatingIds.size > 0) {
setAnimatingIds(newAnimatingIds);
setTimeout(() => setAnimatingIds(new Set()), 800);
}
setPreviousStatuses(
statuses
.slice()
.sort(
(a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0),
),
);
}, [statuses]);
const handleSelectUser = (id: Id<'users'>) => {
setSelectedUserIds((prev) =>
prev.some((i) => i === id)
? prev.filter((prevId) => prevId !== id)
: [...prev, id],
);
};
const handleSelectAll = () => {
if (selectAll) setSelectedUserIds([]);
else setSelectedUserIds(statuses.map((s) => s.user.id));
setSelectAll(!selectAll);
};
const handleUpdateStatus = async () => {
const message = statusInput.trim();
setUpdatingStatus(true);
try {
if (message.length < 3 || message.length > 80) {
throw new Error('Status must be between 3 & 80 characters');
}
if (selectedUserIds.length === 0 && user?.id) {
await bulkCreate({ message, userIds: [user.id] });
} else {
await bulkCreate({ message, userIds: selectedUserIds });
}
toast.success('Status updated.');
setSelectedUserIds([]);
setSelectAll(false);
setStatusInput('');
} catch (error) {
toast.error(`Update failed. ${error as Error}`);
} finally {
setUpdatingStatus(false);
}
};
const getStatusAge = (updatedAt: number) => {
const diff = Date.now() - updatedAt;
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `${hours}h ${minutes}m ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
};
const containerCn = ccn({
context: tvMode,
className: 'max-w-4xl mx-auto',
on: 'px-6',
off: 'px-4 sm:px-6',
});
const tabsCn = ccn({
context: tvMode,
className: 'w-full py-4 sm:py-8',
on: 'hidden',
off: '',
});
const headerCn = ccn({
context: tvMode,
className: 'w-full mb-2',
on: 'hidden',
off: 'hidden sm:flex justify-end items-center',
});
return (
<div className={containerCn}>
<Tabs defaultValue='status'>
<TabsList className={tabsCn}>
<TabsTrigger value='status' className='py-3 sm:py-8'>
<div className='flex items-center gap-2 sm:gap-3'>
<Activity className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
<h1 className='text-base sm:text-2xl font-bold'>Team Status</h1>
</div>
</TabsTrigger>
<TabsTrigger value='history' className='py-3 sm:py-8'>
<div className='flex items-center gap-2 sm:gap-3'>
<History className='text-primary w-4 h-4 sm:w-5 sm:h-5' />
<h1 className='text-base sm:text-2xl font-bold'>
Status History
</h1>
</div>
</TabsTrigger>
</TabsList>
<TabsContent value='status'>
{/* Mobile toolbar */}
<div className='sm:hidden mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Users className='w-4 h-4' />
<span className='text-sm'>{statuses.length} members</span>
</div>
<div className='flex items-center gap-2'>
<Button variant='outline' size='sm' onClick={handleSelectAll}>
{selectAll ? 'Clear' : 'Select all'}
</Button>
<Link
href='/table'
className='text-sm font-medium hover:underline'
>
Table
</Link>
</div>
</div>
{/* Desktop header */}
<div className={headerCn}>
<div className='flex items-center gap-4 text-xs sm:text-base'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Users className='sm:w-4 sm:h-4 w-3 h-3' />
<span>{statuses.length} members</span>
</div>
<div className='flex items-center gap-2 text-xs'>
<Link href='/table' className='font-medium hover:underline'>
Miss the old table?
</Link>
</div>
</div>
</div>
{/* Card list */}
<div className='space-y-2 sm:space-y-3 pb-24 sm:pb-0'>
{statuses.map((statusData) => {
const { user: u, status: s } = statusData;
const isSelected = selectedUserIds.includes(u.id);
const isAnimating = animatingIds.has(u.id);
const isUpdatedByOther = s?.updatedBy?.id !== u.id;
return (
<div
key={u.id}
className={`
relative rounded-xl border transition-all
${isAnimating ? 'bg-primary/5 border-primary/30' : ''}
${
isSelected
? 'border-primary bg-primary/5'
: 'border-border'
}
${tvMode ? 'p-5' : 'p-3 sm:p-4'}
${!tvMode ? 'active:scale-[0.99]' : ''}
`}
onClick={!tvMode ? () => handleSelectUser(u.id) : undefined}
role='button'
aria-pressed={isSelected}
>
{isSelected && !tvMode && (
<div className='absolute top-3 right-3'>
<CheckCircle2 className='w-5 h-5 text-primary' />
</div>
)}
<div className='flex items-start gap-3 sm:gap-4'>
{/* Avatar */}
<div className='flex-shrink-0'>
<BasedAvatar
src={u.imageUrl}
fullName={u.name ?? 'User'}
className={`
transition-all duration-300
${tvMode ? 'w-18 h-18' : 'w-10 h-10 sm:w-12 sm:h-12'}
${isAnimating ? 'ring-primary/30 ring-4' : ''}
`}
/>
</div>
{/* Content */}
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 sm:gap-3 mb-1'>
<h3
className={`
font-semibold truncate
${tvMode ? 'text-3xl' : 'text-base sm:text-xl'}
`}
title={u.name ?? u.email ?? 'User'}
>
{u.name ?? u.email ?? 'User'}
</h3>
{isUpdatedByOther && s?.updatedBy && (
<div
className='hidden sm:flex items-center gap-2
text-muted-foreground min-w-0'
>
<span className='text-sm'>via</span>
<BasedAvatar
src={s.updatedBy.imageUrl}
fullName={s.updatedBy.name ?? 'User'}
className='w-4 h-4'
/>
<span className='text-sm truncate'>
{s.updatedBy.name ??
s.updatedBy.email ??
'another user'}
</span>
</div>
)}
</div>
<div
className={`
mb-2 sm:mb-3 leading-relaxed break-words
${tvMode ? 'text-2xl' : 'text-[0.95rem] sm:text-lg'}
${
s
? 'text-foreground'
: 'text-muted-foreground italic'
}
line-clamp-2
`}
title={s?.message ?? undefined}
>
{s?.message ?? 'No status yet.'}
</div>
{/* Meta */}
<div
className='flex items-center gap-3 sm:gap-4
text-muted-foreground'
>
<div className='flex items-center gap-1.5'>
<Clock className='w-4 h-4 sm:w-4 sm:h-4' />
<span className='text-xs sm:text-sm'>
{s ? formatTime(s.updatedAt) : '--:--'}
</span>
</div>
<div className='hidden xs:flex items-center gap-1.5'>
<Calendar className='w-4 h-4' />
<span className='text-xs sm:text-sm'>
{s ? formatDate(s.updatedAt) : '--/--'}
</span>
</div>
{s && (
<div className='flex items-center gap-1.5'>
<Activity className='w-4 h-4' />
<span className='text-xs sm:text-sm'>
{getStatusAge(s.updatedAt)}
</span>
</div>
)}
</div>
</div>
{/* Actions */}
{!tvMode && (
<div className='flex flex-col items-end gap-2'>
<Drawer>
<DrawerTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 px-2 sm:px-3'
onClick={(e) => e.stopPropagation()}
>
<History className='w-4 h-4 sm:mr-2' />
<span className='hidden sm:inline'>History</span>
</Button>
</DrawerTrigger>
<StatusHistory user={u} />
</Drawer>
</div>
)}
</div>
{/* Mobile "via user" line */}
{isUpdatedByOther && s?.updatedBy && (
<div
className='sm:hidden mt-2 flex items-center gap-2
text-muted-foreground'
>
<span className='text-xs'>via</span>
<BasedAvatar
src={s.updatedBy.imageUrl}
fullName={s.updatedBy.name ?? 'User'}
className='w-4 h-4'
/>
<span className='text-xs truncate'>
{s.updatedBy.name ??
s.updatedBy.email ??
'another user'}
</span>
</div>
)}
</div>
);
})}
</div>
{/* Desktop composer */}
{!tvMode && (
<Card
className='mt-5 hidden md:block border-2 border-dashed
border-muted-foreground/20 hover:border-primary/50
transition-colors'
>
<CardContent className='p-6'>
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<Zap className='w-5 h-5 text-primary' />
<h3 className='text-lg font-semibold'>Update Status</h3>
{selectedUserIds.length > 0 && (
<span
className='px-2 py-1 bg-primary/10 text-primary
text-sm rounded-full'
>
{selectedUserIds.length} selected
</span>
)}
</div>
<div className='flex gap-3'>
<Input
autoFocus
type='text'
placeholder="What's happening?"
className='flex-1 text-lg h-12'
value={statusInput}
disabled={updatingStatus}
onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!updatingStatus
) {
e.preventDefault();
void handleUpdateStatus();
}
}}
/>
<SubmitButton
onClick={handleUpdateStatus}
disabled={updatingStatus}
className='px-6 h-12'
>
{selectedUserIds.length > 0
? `Update ${selectedUserIds.length} ${
selectedUserIds.length > 1 ? 'users' : 'user'
}`
: 'Update Status'}
</SubmitButton>
</div>
<div className='flex justify-between items-center'>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={handleSelectAll}
>
{selectAll ? 'Deselect All' : 'Select All'}
</Button>
</div>
<div className='text-sm text-muted-foreground'>
{statusInput.length}/80 characters
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Mobile sticky composer */}
{!tvMode && (
<div
className='md:hidden fixed bottom-0 left-0 right-0 z-50
border-t bg-background/95 backdrop-blur
supports-[backdrop-filter]:bg-background/60 p-3
pb-[calc(0.75rem+env(safe-area-inset-bottom))]'
>
<div className='flex items-center justify-between mb-2'>
{selectedUserIds.length > 0 ? (
<span className='text-xs text-muted-foreground'>
{selectedUserIds.length} selected
</span>
) : (
<span className='text-xs text-muted-foreground'>
Update your status
</span>
)}
<Button variant='outline' size='sm' onClick={handleSelectAll}>
{selectAll ? 'Clear' : 'Select all'}
</Button>
</div>
<div className='flex gap-2'>
<Input
type='text'
placeholder="What's happening?"
className='h-11 text-base'
value={statusInput}
disabled={updatingStatus}
onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) {
e.preventDefault();
void handleUpdateStatus();
}
}}
/>
<SubmitButton
onClick={handleUpdateStatus}
disabled={updatingStatus}
className='h-11 px-4'
>
Update
</SubmitButton>
</div>
</div>
)}
</TabsContent>
<TabsContent value='history'>
<HistoryTable />
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,301 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react';
import { api } from '~/convex/_generated/api';
import { type Id } from '~/convex/_generated/dataModel';
import { useTVMode } from '@/components/providers';
import {
BasedAvatar,
Button,
Card,
CardContent,
Drawer,
DrawerTrigger,
Input,
SubmitButton,
} from '@/components/ui';
import { toast } from 'sonner';
import { ccn, formatTime, formatDate } from '@/lib/utils';
import { Clock, Calendar, CheckCircle2 } from 'lucide-react';
import { StatusHistory } from '@/components/layout/status';
type StatusTableProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
preloadedStatuses: Preloaded<typeof api.statuses.getCurrentForAll>;
};
export const StatusTable = ({
preloadedUser,
preloadedStatuses,
}: StatusTableProps) => {
const user = usePreloadedQuery(preloadedUser);
const statuses = usePreloadedQuery(preloadedStatuses);
const { tvMode } = useTVMode();
const [selectedUserIds, setSelectedUserIds] = useState<Id<'users'>[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [updatingStatus, setUpdatingStatus] = useState(false);
const bulkCreate = useMutation(api.statuses.bulkCreate);
const handleSelectUser = (id: Id<'users'>) => {
setSelectedUserIds((prev) =>
prev.some((i) => i === id)
? prev.filter((prevId) => prevId !== id)
: [...prev, id],
);
};
const handleSelectAll = () => {
if (selectAll) setSelectedUserIds([]);
else setSelectedUserIds(statuses.map((s) => s.user.id));
setSelectAll(!selectAll);
};
const handleUpdateStatus = async () => {
const message = statusInput.trim();
setUpdatingStatus(true);
try {
if (message.length < 3 || message.length > 80)
throw new Error('Status must be between 3 & 80 characters');
if (selectedUserIds.length === 0 && user?.id)
await bulkCreate({ message, userIds: [user.id] });
await bulkCreate({ message, userIds: selectedUserIds });
toast.success('Status updated.');
setSelectedUserIds([]);
setSelectAll(false);
setStatusInput('');
} catch (error) {
toast.error(`Update failed. ${error as Error}`);
} finally {
setUpdatingStatus(false);
}
};
const containerCn = ccn({
context: tvMode,
className: 'mx-auto',
on: 'lg:w-11/12 w-full',
off: 'w-5/6',
});
const headerCn = ccn({
context: tvMode,
className: 'w-full mb-2 flex justify-between',
on: '',
off: 'mb-2',
});
const thCn = ccn({
context: tvMode,
className: 'py-4 px-4 border font-semibold ',
on: 'lg:text-6xl xl:min-w-[420px]',
off: 'lg:text-5xl xl:min-w-[320px]',
});
const tdCn = ccn({
context: tvMode,
className: 'py-2 px-2 border',
on: 'lg:text-5xl',
off: 'lg:text-4xl',
});
const tCheckboxCn = `py-3 px-4 border`;
const checkBoxCn = `lg:scale-200 cursor-pointer`;
return (
<div className={containerCn}>
<div className={headerCn}>
<div className='flex items-center gap-2'>
{!tvMode && (
<div className='flex flex-row gap-2 text-xs'>
<p className='text-muted-foreground'>Tired of the old table? </p>
<Link
href='/'
className='italic font-semibold hover:text-primary/80'
>
Try the new status list!
</Link>
</div>
)}
</div>
</div>
<table className='w-full text-center rounded-md'>
<thead>
<tr className='bg-muted'>
{!tvMode && (
<th className={tCheckboxCn}>
<input
type='checkbox'
className={checkBoxCn}
checked={selectAll}
onChange={handleSelectAll}
/>
</th>
)}
<th className={thCn}>Technician</th>
<th className={thCn}>
<Drawer>
<DrawerTrigger className='hover:text-foreground/60 cursor-pointer'>
Status
</DrawerTrigger>
<StatusHistory />
</Drawer>
</th>
<th className={thCn}>Updated At</th>
</tr>
</thead>
<tbody>
{statuses.map((status, i) => {
const { user: u, status: s } = status;
const isSelected = selectedUserIds.includes(u.id);
return (
<tr
key={u.id}
className={`
${i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
${isSelected ? 'ring-2 ring-primary' : ''}
hover:bg-muted/75 transition-all duration-300
`}
>
{!tvMode && (
<td className={tCheckboxCn}>
<input
type='checkbox'
className={checkBoxCn}
checked={isSelected}
onChange={() => handleSelectUser(u.id)}
/>
</td>
)}
<td className={tdCn}>
<div className='flex items-center gap-3'>
<BasedAvatar
src={u.imageUrl}
fullName={u.name}
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
/>
<div>
<p> {u.name ?? 'Technician #' + (i + 1)} </p>
{s?.updatedBy && s.updatedBy.id !== u.id && (
<div className='flex items-center gap-1 text-muted-foreground'>
<BasedAvatar
src={s.updatedBy.imageUrl}
fullName={s.updatedBy.name}
className='w-5 h-5'
/>
<span className={tvMode ? 'text-xl' : 'text-base'}>
Updated by {s.updatedBy.name}
</span>
</div>
)}
</div>
</div>
</td>
<td className={tdCn}>
<Drawer>
<DrawerTrigger>{s?.message}</DrawerTrigger>
<StatusHistory user={u} />
</Drawer>
</td>
<td className={tdCn}>
<Drawer>
<DrawerTrigger>
<div className='flex w-full'>
<div className='flex flex-col my-auto items-start'>
<div className='flex gap-4 my-1'>
<Clock
className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`}
/>
<p
className={`${tvMode ? 'text-4xl' : 'text-3xl'}`}
>
{s ? formatTime(s.updatedAt) : '--:--'}
</p>
</div>
<div className='flex gap-4 my-1'>
<Calendar
className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`}
/>
<p
className={`${tvMode ? 'text-4xl' : 'text-3xl'}`}
>
{s ? formatDate(s.updatedAt) : '--:--'}
</p>
</div>
</div>
</div>
</DrawerTrigger>
<StatusHistory user={u} />
</Drawer>
</td>
</tr>
);
})}
</tbody>
</table>
{statuses.length === 0 && (
<div className='p-8 text-center'>
<p
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
>
No status updates yet
</p>
</div>
)}
{!tvMode && (
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
<Input
autoFocus
type='text'
placeholder='New Status'
className={
'min-w-[120px] lg:max-w-[400px] py-6 px-3 rounded-xl \
border bg-background lg:text-2xl focus:outline-none \
focus:ring-2 focus:ring-primary'
}
value={statusInput}
disabled={updatingStatus}
onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) {
e.preventDefault();
void handleUpdateStatus();
}
}}
/>
<SubmitButton
className={
'px-8 rounded-xl font-semibold lg:text-2xl \
disabled:opacity-50 disabled:cursor-not-allowed \
cursor-pointer'
}
onClick={handleUpdateStatus}
disabled={updatingStatus}
pendingText='Updating...'
>
{selectedUserIds.length > 0
? `Update status for ${selectedUserIds.length}
${selectedUserIds.length > 1 ? 'users' : 'user'}`
: 'Update status'}
</SubmitButton>
</div>
)}
{/* Global Status History Drawer */}
{!tvMode && (
<div className='flex justify-center mt-6'>
<Drawer>
<DrawerTrigger asChild>
<Button
variant='outline'
className={tvMode ? 'text-3xl p-6' : ''}
>
View All Status History
</Button>
</DrawerTrigger>
<StatusHistory />
</Drawer>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,15 @@
'use client';
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
import { ConvexReactClient } from 'convex/react';
import { type ReactNode } from 'react';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => {
return (
<ConvexAuthNextjsProvider client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
};

View File

@@ -0,0 +1,164 @@
'use client';
import React, { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { cn } from '@/lib/utils';
type TVModeContextProps = {
tvMode: boolean;
toggleTVMode: () => void;
};
type TVToggleProps = {
buttonClassName?: ComponentProps<typeof Button>['className'];
buttonProps?: Omit<ComponentProps<typeof Button>, 'className'>;
size?: number;
};
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
const TVModeProvider = ({ children }: { children: ReactNode }) => {
const [tvMode, setTVMode] = useState(false);
const toggleTVMode = () => {
setTVMode((prev) => !prev);
};
return (
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
{children}
</TVModeContext.Provider>
);
};
const useTVMode = () => {
const context = useContext(TVModeContext);
if (!context) {
throw new Error('useTVMode must be used within a TVModeProvider');
}
return context;
};
// TV Icon Component with animations
const TVIcon = ({ tvMode, size = 25 }: { tvMode: boolean; size?: number }) => {
return (
<div
className='relative transition-all duration-300 ease-in-out'
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
className='transition-all duration-300 ease-in-out'
>
{/* TV Screen */}
<rect
x='3'
y='6'
width='18'
height='12'
rx='2'
className={cn(
'stroke-current stroke-2 fill-none transition-all duration-300',
tvMode ? 'stroke-blue-500 animate-pulse' : 'stroke-current',
)}
/>
{/* TV Stand */}
<path
d='M8 18h8M12 18v2'
className='stroke-current stroke-2 transition-all duration-300'
/>
{/* Corner arrows - animate based on mode */}
<g
className={cn(
'transition-all duration-300 ease-in-out origin-center',
tvMode ? 'scale-75 opacity-100' : 'scale-100 opacity-70',
)}
>
{tvMode ? (
// Exit fullscreen arrows (pointing inward)
<>
<path
d='M6 8l2 2M6 8h2M6 8v2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
<path
d='M18 8l-2 2M18 8h-2M18 8v2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
<path
d='M6 16l2-2M6 16h2M6 16v-2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
<path
d='M18 16l-2-2M18 16h-2M18 16v-2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
</>
) : (
// Enter fullscreen arrows (pointing outward)
<>
<path
d='M8 6l-2 2M8 6v2M8 6h-2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
<path
d='M16 6l2 2M16 6v2M16 6h2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
<path
d='M8 18l-2-2M8 18v-2M8 18h-2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
<path
d='M16 18l2-2M16 18v-2M16 18h2'
className='stroke-current stroke-1.5 transition-all duration-300'
/>
</>
)}
</g>
{/* Optional: Screen content indicator */}
<circle
cx='12'
cy='12'
r='1'
className={cn(
'transition-all duration-300',
tvMode ? 'fill-blue-400 animate-ping' : 'fill-current opacity-30',
)}
/>
</svg>
</div>
);
};
const TVToggle = ({
buttonClassName,
buttonProps = {
variant: 'outline',
size: 'default',
},
size = 25,
}: TVToggleProps) => {
const { tvMode, toggleTVMode } = useTVMode();
return (
<Button
onClick={toggleTVMode}
className={cn(
'my-auto cursor-pointer transition-all duration-200 hover:scale-105 active:scale-95',
buttonClassName,
)}
aria-label={tvMode ? 'Exit TV Mode' : 'Enter TV Mode'}
{...buttonProps}
>
<TVIcon tvMode={tvMode} size={size} />
</Button>
);
};
export { TVModeProvider, useTVMode, TVToggle };

View File

@@ -0,0 +1,69 @@
'use client';
import { useEffect, useState, type ComponentProps } from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui';
import { cn } from '@/lib/utils';
const ThemeProvider = ({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};
type ThemeToggleProps = {
size?: number;
buttonProps?: Omit<ComponentProps<typeof Button>, 'onClick'>;
};
const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button {...buttonProps}>
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button>
);
}
const toggleTheme = () => {
if (resolvedTheme === 'dark') setTheme('light');
else setTheme('dark');
};
return (
<Button
variant='outline'
size='icon'
{...buttonProps}
onClick={toggleTheme}
className={cn('cursor-pointer', buttonProps?.className)}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
/>
<Moon
style={{ height: `${size}rem`, width: `${size}rem` }}
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
/>
</Button>
);
};
export { ThemeProvider, ThemeToggle, type ThemeToggleProps };

View File

@@ -0,0 +1,7 @@
export { ConvexClientProvider } from './ConvexClientProvider';
export {
ThemeProvider,
ThemeToggle,
type ThemeToggleProps,
} from './ThemeProvider';
export { TVModeProvider, useTVMode, TVToggle } from './TVModeProvider';

View File

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

View File

@@ -0,0 +1,69 @@
'use client';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { User } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AvatarImage } from '@/components/ui/avatar';
import { type ComponentProps } from 'react';
type BasedAvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & {
src?: string | null;
fullName?: string | null;
imageProps?: Omit<ComponentProps<typeof AvatarImage>, 'data-slot'>;
fallbackProps?: ComponentProps<typeof AvatarPrimitive.Fallback>;
userIconProps?: ComponentProps<typeof User>;
};
const BasedAvatar = ({
src = null,
fullName = null,
imageProps,
fallbackProps,
userIconProps = {
size: 32,
},
className,
...props
}: BasedAvatarProps) => {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'cursor-pointer relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
>
{src ? (
<AvatarImage
{...imageProps}
src={src}
className={imageProps?.className}
/>
) : (
<AvatarPrimitive.Fallback
{...fallbackProps}
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
fallbackProps?.className,
)}
>
{fullName ? (
fullName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
) : (
<User
{...userIconProps}
className={cn('', userIconProps?.className)}
/>
)}
</AvatarPrimitive.Fallback>
)}
</AvatarPrimitive.Root>
);
};
export { BasedAvatar };

View File

@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
type BasedProgressProps = React.ComponentProps<
typeof ProgressPrimitive.Root
> & {
/** how many ms between updates */
intervalMs?: number;
/** fraction of the remaining distance to add each tick */
alpha?: number;
};
const BasedProgress = ({
intervalMs = 50,
alpha = 0.1,
className,
value = 0,
...props
}: BasedProgressProps) => {
const [progress, setProgress] = React.useState<number>(value ?? 0);
React.useEffect(() => {
const id = window.setInterval(() => {
setProgress((prev) => {
const next = prev + (100 - prev) * alpha;
return Math.min(100, Math.round(next * 10) / 10);
});
}, intervalMs);
return () => window.clearInterval(id);
}, [intervalMs, alpha]);
return (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary h-full w-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (progress ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
};
export { BasedProgress };

View File

@@ -0,0 +1,59 @@
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:bg-input/30 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',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

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

View File

@@ -0,0 +1,135 @@
'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',
'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-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='drawer-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot='drawer-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot='drawer-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
export { Avatar, AvatarImage, AvatarFallback } from './avatar';
export { BasedAvatar } from './based-avatar';
export { BasedProgress } from './based-progress';
export { Button, buttonVariants } from './button';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
} from './card';
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
} from './drawer';
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './dropdown-menu';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
} from './form';
export {
type ImageCropProps,
type ImageCropApplyProps,
type ImageCropContentProps,
type ImageCropResetProps,
type CropperProps,
Cropper,
ImageCrop,
ImageCropApply,
ImageCropContent,
ImageCropReset,
} from './shadcn-io/image-crop';
export { Input } from './input';
export { Label } from './label';
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} from './pagination';
export { Progress } from './progress';
export { ScrollArea, ScrollBar } from './scroll-area';
export { Separator } from './separator';
export { StatusMessage } from './status-message';
export { SubmitButton } from './submit-button';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from './table';
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
export { Toaster } from './sonner';

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

View File

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

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role='navigation'
aria-label='pagination'
data-slot='pagination'
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='pagination-content'
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot='pagination-item' {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot='pagination-link'
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to previous page'
size='default'
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className='hidden sm:block'>Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to next page'
size='default'
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className='hidden sm:block'>Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot='pagination-ellipsis'
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className='size-4' />
<span className='sr-only'>More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary h-full w-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot='scroll-area'
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot='scroll-area-viewport'
className='focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1'
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot='scroll-area-scrollbar'
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot='scroll-area-thumb'
className='bg-border relative flex-1 rounded-full'
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator'
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,368 @@
'use client';
import { Button } from '@/components/ui';
import { CropIcon, RotateCcwIcon } from 'lucide-react';
import { Slot } from 'radix-ui';
import {
type ComponentProps,
type CSSProperties,
createContext,
type MouseEvent,
type ReactNode,
type RefObject,
type SyntheticEvent,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import ReactCrop, {
centerCrop,
makeAspectCrop,
type PercentCrop,
type PixelCrop,
type ReactCropProps,
} from 'react-image-crop';
import { cn } from '@/lib/utils';
import 'react-image-crop/dist/ReactCrop.css';
const centerAspectCrop = (
mediaWidth: number,
mediaHeight: number,
aspect: number | undefined,
): PercentCrop =>
centerCrop(
aspect
? makeAspectCrop(
{
unit: '%',
width: 90,
},
aspect,
mediaWidth,
mediaHeight,
)
: { x: 0, y: 0, width: 90, height: 90, unit: '%' },
mediaWidth,
mediaHeight,
);
const getCroppedPngImage = async (
imageSrc: HTMLImageElement,
scaleFactor: number,
pixelCrop: PixelCrop,
maxImageSize: number,
): Promise<string> => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Context is null, this should never happen.');
}
const scaleX = imageSrc.naturalWidth / imageSrc.width;
const scaleY = imageSrc.naturalHeight / imageSrc.height;
ctx.imageSmoothingEnabled = false;
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
imageSrc,
pixelCrop.x * scaleX,
pixelCrop.y * scaleY,
pixelCrop.width * scaleX,
pixelCrop.height * scaleY,
0,
0,
canvas.width,
canvas.height,
);
const croppedImageUrl = canvas.toDataURL('image/png');
const response = await fetch(croppedImageUrl);
const blob = await response.blob();
if (blob.size > maxImageSize) {
return await getCroppedPngImage(
imageSrc,
scaleFactor * 0.9,
pixelCrop,
maxImageSize,
);
}
return croppedImageUrl;
};
type ImageCropContextType = {
file: File;
maxImageSize: number;
imgSrc: string;
crop: PercentCrop | undefined;
completedCrop: PixelCrop | null;
imgRef: RefObject<HTMLImageElement | null>;
onCrop?: (croppedImage: string) => void;
reactCropProps: Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>;
handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void;
handleComplete: (
pixelCrop: PixelCrop,
percentCrop: PercentCrop,
) => Promise<void>;
onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void;
applyCrop: () => Promise<void>;
resetCrop: () => void;
};
const ImageCropContext = createContext<ImageCropContextType | null>(null);
const useImageCrop = () => {
const context = useContext(ImageCropContext);
if (!context) {
throw new Error('ImageCrop components must be used within ImageCrop');
}
return context;
};
export type ImageCropProps = {
file: File;
maxImageSize?: number;
onCrop?: (croppedImage: string) => void;
children: ReactNode;
onChange?: ReactCropProps['onChange'];
onComplete?: ReactCropProps['onComplete'];
} & Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>;
export const ImageCrop = ({
file,
maxImageSize = 1024 * 1024 * 5,
onCrop,
children,
onChange,
onComplete,
...reactCropProps
}: ImageCropProps) => {
const imgRef = useRef<HTMLImageElement | null>(null);
const [imgSrc, setImgSrc] = useState<string>('');
const [crop, setCrop] = useState<PercentCrop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
const [initialCrop, setInitialCrop] = useState<PercentCrop>();
useEffect(() => {
const reader = new FileReader();
reader.addEventListener('load', () =>
setImgSrc(reader.result?.toString() || ''),
);
reader.readAsDataURL(file);
}, [file]);
const onImageLoad = useCallback(
(e: SyntheticEvent<HTMLImageElement>) => {
const { width, height } = e.currentTarget;
const newCrop = centerAspectCrop(width, height, reactCropProps.aspect);
setCrop(newCrop);
setInitialCrop(newCrop);
},
[reactCropProps.aspect],
);
const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {
setCrop(percentCrop);
onChange?.(pixelCrop, percentCrop);
};
// biome-ignore lint/suspicious/useAwait: "onComplete is async"
const handleComplete = async (
pixelCrop: PixelCrop,
percentCrop: PercentCrop,
) => {
setCompletedCrop(pixelCrop);
onComplete?.(pixelCrop, percentCrop);
};
const applyCrop = async () => {
if (!(imgRef.current && completedCrop)) {
return;
}
const croppedImage = await getCroppedPngImage(
imgRef.current,
1,
completedCrop,
maxImageSize,
);
onCrop?.(croppedImage);
};
const resetCrop = () => {
if (initialCrop) {
setCrop(initialCrop);
setCompletedCrop(null);
}
};
const contextValue: ImageCropContextType = {
file,
maxImageSize,
imgSrc,
crop,
completedCrop,
imgRef,
onCrop,
reactCropProps,
handleChange,
handleComplete,
onImageLoad,
applyCrop,
resetCrop,
};
return (
<ImageCropContext.Provider value={contextValue}>
{children}
</ImageCropContext.Provider>
);
};
export type ImageCropContentProps = {
style?: CSSProperties;
className?: string;
};
export const ImageCropContent = ({
style,
className,
}: ImageCropContentProps) => {
const {
imgSrc,
crop,
handleChange,
handleComplete,
onImageLoad,
imgRef,
reactCropProps,
} = useImageCrop();
const shadcnStyle = {
'--rc-border-color': 'var(--color-border)',
'--rc-focus-color': 'var(--color-primary)',
} as CSSProperties;
return (
<ReactCrop
className={cn('max-h-[277px] max-w-full', className)}
crop={crop}
onChange={handleChange}
onComplete={handleComplete}
style={{ ...shadcnStyle, ...style }}
{...reactCropProps}
>
{imgSrc && (
<img
alt='crop'
className='size-full'
onLoad={onImageLoad}
ref={imgRef}
src={imgSrc}
/>
)}
</ReactCrop>
);
};
export type ImageCropApplyProps = ComponentProps<'button'> & {
asChild?: boolean;
};
export const ImageCropApply = ({
asChild = false,
children,
onClick,
...props
}: ImageCropApplyProps) => {
const { applyCrop } = useImageCrop();
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
await applyCrop();
onClick?.(e);
};
if (asChild) {
return (
<Slot.Root onClick={handleClick} {...props}>
{children}
</Slot.Root>
);
}
return (
<Button onClick={handleClick} size='icon' variant='ghost' {...props}>
{children ?? <CropIcon className='size-4' />}
</Button>
);
};
export type ImageCropResetProps = ComponentProps<'button'> & {
asChild?: boolean;
};
export const ImageCropReset = ({
asChild = false,
children,
onClick,
...props
}: ImageCropResetProps) => {
const { resetCrop } = useImageCrop();
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
resetCrop();
onClick?.(e);
};
if (asChild) {
return (
<Slot.Root onClick={handleClick} {...props}>
{children}
</Slot.Root>
);
}
return (
<Button onClick={handleClick} size='icon' variant='ghost' {...props}>
{children ?? <RotateCcwIcon className='size-4' />}
</Button>
);
};
// Keep the original Cropper component for backward compatibility
export type CropperProps = Omit<ReactCropProps, 'onChange'> & {
file: File;
maxImageSize?: number;
onCrop?: (croppedImage: string) => void;
onChange?: ReactCropProps['onChange'];
};
export const Cropper = ({
onChange,
onComplete,
onCrop,
style,
className,
file,
maxImageSize,
...props
}: CropperProps) => (
<ImageCrop
file={file}
maxImageSize={maxImageSize}
onChange={onChange}
onComplete={onComplete}
onCrop={onCrop}
{...props}
>
<ImageCropContent className={className} style={style} />
</ImageCrop>
);

View File

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

View File

@@ -0,0 +1,58 @@
import { type ComponentProps } from 'react';
import { cn } from '@/lib/utils';
type Message = { success: string } | { error: string } | { message: string };
type StatusMessageProps = {
message: Message;
containerProps?: ComponentProps<'div'>;
textProps?: ComponentProps<'div'>;
};
export const StatusMessage = ({
message,
containerProps,
textProps,
}: StatusMessageProps) => {
return (
<div className='flex flex-col items-center w-full'>
{'success' in message && (
<div
{...containerProps}
className={cn(
'flex flex-col items-center w-11/12 rounded-md p-2',
'dark:bg-green-500/20 bg-green-700/20 border-2',
'dark:border-green-500/50 border-green-700/50',
containerProps?.className,
)}
>
<p {...textProps}>{message.success}</p>
</div>
)}
{'error' in message && (
<div
{...containerProps}
className={cn(
'flex flex-col items-center w-11/12 rounded-md p-2',
'bg-destructive/20 border-2 border-destructive/80',
containerProps?.className,
)}
>
<p {...textProps}>{message.error}</p>
</div>
)}
{'message' in message && (
<div
{...containerProps}
className={cn(
'flex flex-col items-center w-11/12 rounded-md p-2',
'bg-accent/20 border-2 border-primary/80',
containerProps?.className,
)}
>
<p {...textProps}>{message.message}</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,51 @@
'use client';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export type SubmitButtonProps = Omit<
ComponentProps<typeof Button>,
'type' | 'aria-disabled'
> & {
pendingText?: string;
pendingTextProps?: ComponentProps<'p'>;
loaderProps?: ComponentProps<typeof Loader2>;
};
export const SubmitButton = ({
children,
className,
pendingText = 'Submitting...',
pendingTextProps,
loaderProps,
...props
}: SubmitButtonProps) => {
const { pending } = useFormStatus();
return (
<Button
type='submit'
aria-disabled={pending}
{...props}
className={cn('cursor-pointer', className)}
>
{pending || props.disabled ? (
<>
<Loader2
{...loaderProps}
className={cn('mr-2 h-4 w-4 animate-spin', loaderProps?.className)}
/>
<p
{...pendingTextProps}
className={cn('text-sm font-medium', pendingTextProps?.className)}
>
{pendingText}
</p>
</>
) : (
children
)}
</Button>
);
};

View File

@@ -0,0 +1,116 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot='table-container'
className='relative w-full overflow-x-auto'
>
<table
data-slot='table'
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot='table-header'
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot='table-body'
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot='table-footer'
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot='table-row'
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot='table-head'
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot='table-cell'
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot='table-caption'
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

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

36
apps/next/src/env.js Normal file
View File

@@ -0,0 +1,36 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
SKIP_ENV_VALIDATION: z.boolean().default(false),
SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(true),
},
client: {
NEXT_PUBLIC_CONVEX_URL: z.url(),
NEXT_PUBLIC_SITE_URL: z.url().default('http://localhost:3000'),
NEXT_PUBLIC_SENTRY_DSN: z.url(),
NEXT_PUBLIC_SENTRY_URL: z.url(),
NEXT_PUBLIC_SENTRY_ORG: z.string().default('gib'),
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
NEXT_PUBLIC_SENTRY_PROJECT_NAME:
process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});

View File

@@ -0,0 +1,16 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

View File

@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
import type { Instrumentation } from 'next';
export const register = async () => {
await import('../sentry.server.config');
};
export const onRequestError: Instrumentation.onRequestError = (...args) => {
Sentry.captureRequestError(...args);
};

View File

@@ -0,0 +1,369 @@
import type { Metadata } from 'next';
import * as Sentry from '@sentry/nextjs';
export const generateMetadata = (): Metadata => {
return {
title: {
template: '%s | Tech Tracker',
default: 'Tech Tracker',
},
description:
'App used by COG IT employees to \
update their status throughout the day.',
applicationName: 'Tech Tracker',
keywords:
'Tech Tracker, City of Gulfport, Information Technology, T3 Template, ' +
'Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib',
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
creator: 'Gib Brown',
publisher: 'Gib Brown',
formatDetection: {
email: false,
address: false,
telephone: false,
},
robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
noimageindex: false,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
icons: {
icon: [
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
{
url: '/favicon-16.png',
type: 'image/png',
sizes: '16x16',
},
{
url: '/favicon-32.png',
type: 'image/png',
sizes: '32x32',
},
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' },
{
url: '/favicon.ico',
type: 'image/x-icon',
sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-16.png',
type: 'image/png',
sizes: '16x16',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-32.png',
type: 'image/png',
sizes: '32x32',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-36.png',
type: 'image/png',
sizes: '36x36',
},
{
url: '/appicon/icon-48.png',
type: 'image/png',
sizes: '48x48',
},
{
url: '/appicon/icon-72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: '/appicon/icon-96.png',
type: 'image/png',
sizes: '96x96',
},
{
url: '/appicon/icon-144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: '/appicon/icon-36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
shortcut: [
{
url: '/appicon/icon-36.png',
type: 'image/png',
sizes: '36x36',
},
{
url: '/appicon/icon-48.png',
type: 'image/png',
sizes: '48x48',
},
{
url: '/appicon/icon-72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: '/appicon/icon-96.png',
type: 'image/png',
sizes: '96x96',
},
{
url: '/appicon/icon-144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: '/appicon/icon-36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
apple: [
{
url: 'appicon/icon-57.png',
type: 'image/png',
sizes: '57x57',
},
{
url: 'appicon/icon-60.png',
type: 'image/png',
sizes: '60x60',
},
{
url: 'appicon/icon-72.png',
type: 'image/png',
sizes: '72x72',
},
{
url: 'appicon/icon-76.png',
type: 'image/png',
sizes: '76x76',
},
{
url: 'appicon/icon-114.png',
type: 'image/png',
sizes: '114x114',
},
{
url: 'appicon/icon-120.png',
type: 'image/png',
sizes: '120x120',
},
{
url: 'appicon/icon-144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: 'appicon/icon-152.png',
type: 'image/png',
sizes: '152x152',
},
{
url: 'appicon/icon-180.png',
type: 'image/png',
sizes: '180x180',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
},
{
url: 'appicon/icon-57.png',
type: 'image/png',
sizes: '57x57',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-60.png',
type: 'image/png',
sizes: '60x60',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-76.png',
type: 'image/png',
sizes: '76x76',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-114.png',
type: 'image/png',
sizes: '114x114',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-120.png',
type: 'image/png',
sizes: '120x120',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-152.png',
type: 'image/png',
sizes: '152x152',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-180.png',
type: 'image/png',
sizes: '180x180',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
],
other: [
{
rel: 'apple-touch-icon-precomposed',
url: '/appicon/icon-precomposed.png',
type: 'image/png',
sizes: '180x180',
},
],
},
other: {
...Sentry.getTraceData(),
},
appleWebApp: {
title: 'Tech Tracker',
statusBarStyle: 'black-translucent',
startupImage: [
'/icons/apple/splash-768x1004.png',
{
url: '/icons/apple/splash-1536x2008.png',
media: '(device-width: 768px) and (device-height: 1024px)',
},
],
},
verification: {
google: 'google',
yandex: 'yandex',
yahoo: 'yahoo',
},
category: 'technology',
/*
appLinks: {
ios: {
url: 'https://techtracker.gbrown.org/ios',
app_store_id: 'com.gbrown.techtracker',
},
android: {
package: 'https://techtracker.gbrown.org/android',
app_name: 'app_t3_template',
},
web: {
url: 'https://techtracker.gbrown.org',
should_fallback: true,
},
},
*/
};
};

View File

@@ -0,0 +1,201 @@
import { type NextRequest, NextResponse } from 'next/server';
// In-memory stores for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const ip404Attempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Ban Arctic Wolf Explicitly
bannedIPs.add('::ffff:10.0.1.49');
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
// Your existing patterns
/web-inf/i,
/\.jsp/i,
/\.php/i,
/puttest/i,
/WEB-INF/i,
/\.xml$/i,
/perl/i,
/xampp/i,
/phpwebgallery/i,
/FileManager/i,
/standalonemanager/i,
/h2console/i,
/WebAdmin/i,
/login_form\.php/i,
/%2e/i,
/%u002e/i,
/\.%00/i,
/\.\./,
/lcgi/i,
// New patterns from your logs
/\/appliance\//i,
/bomgar/i,
/netburner-logo/i,
/\/ui\/images\//i,
/logon_merge/i,
/logon_t\.gif/i,
/login_top\.gif/i,
/theme1\/images/i,
/\.well-known\/acme-challenge\/.*\.jpg$/i,
/\.well-known\/pki-validation\/.*\.jpg$/i,
// Path traversal and system file access patterns
/\/etc\/passwd/i,
/\/etc%2fpasswd/i,
/\/etc%5cpasswd/i,
/\/\/+etc/i,
/\\\\+.*etc/i,
/%2f%2f/i,
/%5c%5c/i,
/\/\/+/,
/\\\\+/,
/%00/i,
/%23/i,
// Encoded path traversal attempts
/%2e%2e/i,
/%252e/i,
/%c0%ae/i,
/%c1%9c/i,
];
// Suspicious HTTP methods
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
// 404 rate limiting settings
const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
const MAX_404_ATTEMPTS = 10; // Max 404s before ban
const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
const cfConnectingIP = request.headers.get('cf-connecting-ip');
if (forwarded) return (forwarded.split(',')[0] ?? '').trim();
if (realIP) return realIP;
if (cfConnectingIP) return cfConnectingIP;
return request.headers.get('host') ?? 'unknown';
};
const isPathSuspicious = (pathname: string): boolean => {
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
};
const isMethodSuspicious = (method: string): boolean => {
return SUSPICIOUS_METHODS.includes(method);
};
const updateIPAttempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ipAttempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
ipAttempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip);
ipAttempts.delete(ip);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
const update404Attempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ip404Attempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_404_WINDOW) {
ip404Attempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_404_ATTEMPTS) {
bannedIPs.add(ip);
ip404Attempts.delete(ip);
console.log(
`🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`,
);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
const { pathname } = request.nextUrl;
const method = request.method;
const ip = getClientIP(request);
// Check if IP is already banned
if (bannedIPs.has(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const isSuspiciousPath = isPathSuspicious(pathname);
const isSuspiciousMethod = isMethodSuspicious(method);
// Handle suspicious activity
if (isSuspiciousPath || isSuspiciousMethod) {
const shouldBan = updateIPAttempts(ip);
if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned. Please fuck off.', {
status: 403,
});
}
return new NextResponse('Not Found', { status: 404 });
}
return null;
};
// Call this function when you detect a 404 response
export const handle404Response = (
request: NextRequest,
): NextResponse | null => {
const ip = getClientIP(request);
if (bannedIPs.has(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const shouldBan = update404Attempts(ip);
if (shouldBan) {
return new NextResponse('Access denied - IP banned for excessive 404s.', {
status: 403,
});
}
return null;
};

View File

@@ -0,0 +1,6 @@
export const PASSWORD_MIN = 8;
export const PASSWORD_MAX = 100;
export const PASSWORD_REGEX =
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
export type Timestamp = number | string | Date;

View File

@@ -0,0 +1,57 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type Timestamp } from '@/lib/types';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const ccn = ({
context,
className,
on = '',
off = '',
}: {
context: boolean;
className: string;
on: string;
off: string;
}) => {
return twMerge(className, context ? on : off);
};
const toDate = (ts: Timestamp): Date | null => {
if (ts instanceof Date) return isNaN(ts.getTime()) ? null : ts;
if (typeof ts === 'number') {
// Heuristic: treat small numbers as seconds
const ms = ts < 1_000_000_000_000 ? ts * 1000 : ts;
const d = new Date(ms);
return isNaN(d.getTime()) ? null : d;
}
// string: try numeric first, then ISO/date string
const asNum = Number(ts);
const d =
Number.isFinite(asNum) && asNum !== 0 ? toDate(asNum) : new Date(ts);
return d && !isNaN(d.getTime()) ? d : null;
};
export const formatTime = (timestamp: Timestamp, locale = 'en-US'): string => {
const date = toDate(timestamp);
if (!date) return '--:--';
return date.toLocaleTimeString(locale, {
hour: 'numeric',
minute: 'numeric',
});
};
export const formatDate = (timestamp: Timestamp, locale = 'en-US'): string => {
const date = toDate(timestamp);
if (!date) return '--/--';
return date.toLocaleDateString(locale, {
month: 'long',
day: 'numeric',
});
};

View File

@@ -0,0 +1,31 @@
import {
convexAuthNextjsMiddleware,
createRouteMatcher,
nextjsMiddlewareRedirect,
} from '@convex-dev/auth/nextjs/server';
import { banSuspiciousIPs } from '@/lib/middleware/ban-suspicious-ips';
const isSignInPage = createRouteMatcher(['/signin']);
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
export default convexAuthNextjsMiddleware(async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/signin');
}
});
export const config = {
// The following matcher runs middleware on all routes
// except static assets.
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/((?!.*\\..*|_next).*)',
'/',
'/(api|trpc)(.*)',
],
};

View File

@@ -0,0 +1,167 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@theme inline {
--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-destructive-foreground: var(--destructive-foreground);
--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);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
:root {
--background: oklch(0.9227 0.0011 17.1793);
--foreground: oklch(0.2840 0.0220 262.4967);
--card: oklch(0.9699 0.0013 106.4238);
--card-foreground: oklch(0.2840 0.0220 262.4967);
--popover: oklch(0.9699 0.0013 106.4238);
--popover-foreground: oklch(0.2840 0.0220 262.4967);
--primary: oklch(0.6378 0.1247 281.2150);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.8682 0.0026 48.7143);
--secondary-foreground: oklch(0.4507 0.0152 255.5845);
--muted: oklch(0.9227 0.0011 17.1793);
--muted-foreground: oklch(0.5551 0.0147 266.6154);
--accent: oklch(0.9409 0.0164 322.6966);
--accent-foreground: oklch(0.3774 0.0189 260.6754);
--destructive: oklch(0.6322 0.1310 21.4751);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8682 0.0026 48.7143);
--input: oklch(0.8682 0.0026 48.7143);
--ring: oklch(0.6378 0.1247 281.2150);
--chart-1: oklch(0.6378 0.1247 281.2150);
--chart-2: oklch(0.5608 0.1433 283.1275);
--chart-3: oklch(0.5008 0.1358 283.9499);
--chart-4: oklch(0.4372 0.1108 283.4322);
--chart-5: oklch(0.3928 0.0817 282.8932);
--sidebar: oklch(0.8682 0.0026 48.7143);
--sidebar-foreground: oklch(0.2840 0.0220 262.4967);
--sidebar-primary: oklch(0.6378 0.1247 281.2150);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9409 0.0164 322.6966);
--sidebar-accent-foreground: oklch(0.3774 0.0189 260.6754);
--sidebar-border: oklch(0.8682 0.0026 48.7143);
--sidebar-ring: oklch(0.6378 0.1247 281.2150);
--font-sans: Inter, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 1.0rem;
--shadow-2xs: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 1px 2px 3px hsl(240 1.9608% 60% / 0.18);
--shadow: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 1px 2px 3px hsl(240 1.9608% 60% / 0.18);
--shadow-md: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 2px 4px 3px hsl(240 1.9608% 60% / 0.18);
--shadow-lg: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 4px 6px 3px hsl(240 1.9608% 60% / 0.18);
--shadow-xl: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.18), 2px 8px 10px 3px hsl(240 1.9608% 60% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(240 1.9608% 60% / 0.45);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2236 0.0049 67.5717);
--foreground: oklch(0.9301 0.0075 260.7315);
--card: oklch(0.2793 0.0057 56.1503);
--card-foreground: oklch(0.9301 0.0075 260.7315);
--popover: oklch(0.2793 0.0057 56.1503);
--popover-foreground: oklch(0.9301 0.0075 260.7315);
--primary: oklch(0.7223 0.0946 279.6746);
--primary-foreground: oklch(0.2236 0.0049 67.5717);
--secondary: oklch(0.3352 0.0055 56.2080);
--secondary-foreground: oklch(0.8726 0.0059 264.5296);
--muted: oklch(0.2793 0.0057 56.1503);
--muted-foreground: oklch(0.7176 0.0111 261.7826);
--accent: oklch(0.3889 0.0053 56.2463);
--accent-foreground: oklch(0.8726 0.0059 264.5296);
--destructive: oklch(0.6322 0.1310 21.4751);
--destructive-foreground: oklch(0.2236 0.0049 67.5717);
--border: oklch(0.3352 0.0055 56.2080);
--input: oklch(0.3352 0.0055 56.2080);
--ring: oklch(0.7223 0.0946 279.6746);
--chart-1: oklch(0.7223 0.0946 279.6746);
--chart-2: oklch(0.6378 0.1247 281.2150);
--chart-3: oklch(0.5608 0.1433 283.1275);
--chart-4: oklch(0.5008 0.1358 283.9499);
--chart-5: oklch(0.4372 0.1108 283.4322);
--sidebar: oklch(0.3352 0.0055 56.2080);
--sidebar-foreground: oklch(0.9301 0.0075 260.7315);
--sidebar-primary: oklch(0.7223 0.0946 279.6746);
--sidebar-primary-foreground: oklch(0.2236 0.0049 67.5717);
--sidebar-accent: oklch(0.3889 0.0053 56.2463);
--sidebar-accent-foreground: oklch(0.8726 0.0059 264.5296);
--sidebar-border: oklch(0.3352 0.0055 56.2080);
--sidebar-ring: oklch(0.7223 0.0946 279.6746);
--font-sans: Inter, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 1.0rem;
--shadow-2xs: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 1px 2px 3px hsl(0 0% 10.1961% / 0.18);
--shadow: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 1px 2px 3px hsl(0 0% 10.1961% / 0.18);
--shadow-md: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 2px 4px 3px hsl(0 0% 10.1961% / 0.18);
--shadow-lg: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 4px 6px 3px hsl(0 0% 10.1961% / 0.18);
--shadow-xl: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.18), 2px 8px 10px 3px hsl(0 0% 10.1961% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 10.1961% / 0.45);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

36
apps/next/tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "es2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleDetection": "force",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules", ".next"]
}