Move to monorepo for React Native!
This commit is contained in:
14
apps/next/src/app/(auth)/profile/layout.tsx
Normal file
14
apps/next/src/app/(auth)/profile/layout.tsx
Normal 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;
|
28
apps/next/src/app/(auth)/profile/page.tsx
Normal file
28
apps/next/src/app/(auth)/profile/page.tsx
Normal 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;
|
326
apps/next/src/app/(auth)/signin/page.tsx
Normal file
326
apps/next/src/app/(auth)/signin/page.tsx
Normal 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;
|
14
apps/next/src/app/(status)/table/layout.tsx
Normal file
14
apps/next/src/app/(status)/table/layout.tsx
Normal 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;
|
18
apps/next/src/app/(status)/table/page.tsx
Normal file
18
apps/next/src/app/(status)/table/page.tsx
Normal 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;
|
75
apps/next/src/app/global-error.tsx
Normal file
75
apps/next/src/app/global-error.tsx
Normal 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;
|
59
apps/next/src/app/layout.tsx
Normal file
59
apps/next/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
apps/next/src/app/page.tsx
Normal file
18
apps/next/src/app/page.tsx
Normal 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;
|
@@ -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>
|
||||
);
|
||||
};
|
20
apps/next/src/components/layout/header/controls/index.tsx
Normal file
20
apps/next/src/components/layout/header/controls/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
72
apps/next/src/components/layout/header/index.tsx
Normal file
72
apps/next/src/components/layout/header/index.tsx
Normal 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;
|
238
apps/next/src/components/layout/profile/avatar-upload.tsx
Normal file
238
apps/next/src/components/layout/profile/avatar-upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
24
apps/next/src/components/layout/profile/header.tsx
Normal file
24
apps/next/src/components/layout/profile/header.tsx
Normal 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 & how it appears to others.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileHeader };
|
5
apps/next/src/components/layout/profile/index.tsx
Normal file
5
apps/next/src/components/layout/profile/index.tsx
Normal 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';
|
174
apps/next/src/components/layout/profile/reset-password.tsx
Normal file
174
apps/next/src/components/layout/profile/reset-password.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
22
apps/next/src/components/layout/profile/sign-out.tsx
Normal file
22
apps/next/src/components/layout/profile/sign-out.tsx
Normal 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>
|
||||
);
|
||||
};
|
121
apps/next/src/components/layout/profile/user-info.tsx
Normal file
121
apps/next/src/components/layout/profile/user-info.tsx
Normal 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>
|
||||
);
|
||||
};
|
195
apps/next/src/components/layout/status/history/index.tsx
Normal file
195
apps/next/src/components/layout/status/history/index.tsx
Normal 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 & 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>
|
||||
);
|
||||
};
|
3
apps/next/src/components/layout/status/index.tsx
Normal file
3
apps/next/src/components/layout/status/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { StatusHistory } from './history';
|
||||
export { StatusList } from './list';
|
||||
export { StatusTable } from './table';
|
222
apps/next/src/components/layout/status/list/history/index.tsx
Normal file
222
apps/next/src/components/layout/status/list/history/index.tsx
Normal 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 & 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>
|
||||
);
|
||||
};
|
496
apps/next/src/components/layout/status/list/index.tsx
Normal file
496
apps/next/src/components/layout/status/list/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
301
apps/next/src/components/layout/status/table/index.tsx
Normal file
301
apps/next/src/components/layout/status/table/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
15
apps/next/src/components/providers/ConvexClientProvider.tsx
Normal file
15
apps/next/src/components/providers/ConvexClientProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
164
apps/next/src/components/providers/TVModeProvider.tsx
Normal file
164
apps/next/src/components/providers/TVModeProvider.tsx
Normal 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 };
|
69
apps/next/src/components/providers/ThemeProvider.tsx
Normal file
69
apps/next/src/components/providers/ThemeProvider.tsx
Normal 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 };
|
7
apps/next/src/components/providers/index.tsx
Normal file
7
apps/next/src/components/providers/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||
export {
|
||||
ThemeProvider,
|
||||
ThemeToggle,
|
||||
type ThemeToggleProps,
|
||||
} from './ThemeProvider';
|
||||
export { TVModeProvider, useTVMode, TVToggle } from './TVModeProvider';
|
53
apps/next/src/components/ui/avatar.tsx
Normal file
53
apps/next/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot='avatar'
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot='avatar-image'
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot='avatar-fallback'
|
||||
className={cn(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
69
apps/next/src/components/ui/based-avatar.tsx
Normal file
69
apps/next/src/components/ui/based-avatar.tsx
Normal 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 };
|
53
apps/next/src/components/ui/based-progress.tsx
Normal file
53
apps/next/src/components/ui/based-progress.tsx
Normal 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 };
|
59
apps/next/src/components/ui/button.tsx
Normal file
59
apps/next/src/components/ui/button.tsx
Normal 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 };
|
92
apps/next/src/components/ui/card.tsx
Normal file
92
apps/next/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card'
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-header'
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-title'
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-description'
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-action'
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-content'
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-footer'
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
135
apps/next/src/components/ui/drawer.tsx
Normal file
135
apps/next/src/components/ui/drawer.tsx
Normal 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,
|
||||
};
|
257
apps/next/src/components/ui/dropdown-menu.tsx
Normal file
257
apps/next/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot='dropdown-menu-trigger'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot='dropdown-menu-content'
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot='dropdown-menu-item'
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot='dropdown-menu-checkbox-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className='size-4' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot='dropdown-menu-radio-group'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot='dropdown-menu-radio-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className='size-2 fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot='dropdown-menu-label'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot='dropdown-menu-separator'
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot='dropdown-menu-shortcut'
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot='dropdown-menu-sub-trigger'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className='ml-auto size-4' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot='dropdown-menu-sub-content'
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
168
apps/next/src/components/ui/form.tsx
Normal file
168
apps/next/src/components/ui/form.tsx
Normal 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,
|
||||
};
|
83
apps/next/src/components/ui/index.tsx
Normal file
83
apps/next/src/components/ui/index.tsx
Normal 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';
|
21
apps/next/src/components/ui/input.tsx
Normal file
21
apps/next/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot='input'
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground 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 };
|
24
apps/next/src/components/ui/label.tsx
Normal file
24
apps/next/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot='label'
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
127
apps/next/src/components/ui/pagination.tsx
Normal file
127
apps/next/src/components/ui/pagination.tsx
Normal 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,
|
||||
};
|
31
apps/next/src/components/ui/progress.tsx
Normal file
31
apps/next/src/components/ui/progress.tsx
Normal 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 };
|
58
apps/next/src/components/ui/scroll-area.tsx
Normal file
58
apps/next/src/components/ui/scroll-area.tsx
Normal 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 };
|
28
apps/next/src/components/ui/separator.tsx
Normal file
28
apps/next/src/components/ui/separator.tsx
Normal 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 };
|
368
apps/next/src/components/ui/shadcn-io/image-crop/index.tsx
Normal file
368
apps/next/src/components/ui/shadcn-io/image-crop/index.tsx
Normal 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>
|
||||
);
|
25
apps/next/src/components/ui/sonner.tsx
Normal file
25
apps/next/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner';
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className='toaster group'
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
58
apps/next/src/components/ui/status-message.tsx
Normal file
58
apps/next/src/components/ui/status-message.tsx
Normal 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>
|
||||
);
|
||||
};
|
51
apps/next/src/components/ui/submit-button.tsx
Normal file
51
apps/next/src/components/ui/submit-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
116
apps/next/src/components/ui/table.tsx
Normal file
116
apps/next/src/components/ui/table.tsx
Normal 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,
|
||||
};
|
66
apps/next/src/components/ui/tabs.tsx
Normal file
66
apps/next/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot='tabs'
|
||||
className={cn('flex flex-col gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot='tabs-list'
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot='tabs-trigger'
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot='tabs-content'
|
||||
className={cn('flex-1 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
36
apps/next/src/env.js
Normal file
36
apps/next/src/env.js
Normal 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,
|
||||
});
|
16
apps/next/src/instrumentation-client.ts
Normal file
16
apps/next/src/instrumentation-client.ts
Normal 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;
|
10
apps/next/src/instrumentation.ts
Normal file
10
apps/next/src/instrumentation.ts
Normal 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);
|
||||
};
|
369
apps/next/src/lib/metadata.ts
Normal file
369
apps/next/src/lib/metadata.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
*/
|
||||
};
|
||||
};
|
201
apps/next/src/lib/middleware/ban-suspicious-ips.ts
Normal file
201
apps/next/src/lib/middleware/ban-suspicious-ips.ts
Normal 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;
|
||||
};
|
6
apps/next/src/lib/types.ts
Normal file
6
apps/next/src/lib/types.ts
Normal 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;
|
57
apps/next/src/lib/utils.ts
Normal file
57
apps/next/src/lib/utils.ts
Normal 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',
|
||||
});
|
||||
};
|
31
apps/next/src/middleware.ts
Normal file
31
apps/next/src/middleware.ts
Normal 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)(.*)',
|
||||
],
|
||||
};
|
167
apps/next/src/styles/globals.css
Normal file
167
apps/next/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user