Added stuff idek
This commit is contained in:
14
src/app/(auth)/profile/layout.tsx
Normal file
14
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;
|
4
src/app/(auth)/profile/page.tsx
Normal file
4
src/app/(auth)/profile/page.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
const Profile = () => {
|
||||
return <div></div>;
|
||||
};
|
||||
export default Profile;
|
79
src/app/global-error.tsx
Normal file
79
src/app/global-error.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'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;
|
@@ -2,20 +2,24 @@ 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 } from '@/components/providers';
|
||||
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({
|
||||
@@ -39,9 +43,14 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>{children}</ConvexClientProvider>
|
||||
<ConvexClientProvider>
|
||||
<TVModeProvider>
|
||||
<Header />
|
||||
{children}
|
||||
<Toaster />
|
||||
</TVModeProvider>
|
||||
</ConvexClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</PlausibleProvider>
|
||||
|
154
src/app/page.tsx
154
src/app/page.tsx
@@ -6,154 +6,10 @@ import Link from 'next/link';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
const Home = () => {
|
||||
return (
|
||||
<>
|
||||
<header className='sticky top-0 z-10 bg-background p-4 border-b-2 border-slate-200 dark:border-slate-800 flex flex-row justify-between items-center'>
|
||||
Convex + Next.js + Convex Auth
|
||||
<SignOutButton />
|
||||
</header>
|
||||
<main className='p-8 flex flex-col gap-8'>
|
||||
<h1 className='text-4xl font-bold text-center'>
|
||||
Convex + Next.js + Convex Auth
|
||||
</h1>
|
||||
<Content />
|
||||
</main>
|
||||
</>
|
||||
<main>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutButton() {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const { signOut } = useAuthActions();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
className='bg-slate-200 dark:bg-slate-800 text-foreground rounded-md px-2 py-1'
|
||||
onClick={() =>
|
||||
void signOut().then(() => {
|
||||
router.push('/signin');
|
||||
})
|
||||
}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
const { viewer, numbers } =
|
||||
useQuery(api.myFunctions.listNumbers, {
|
||||
count: 10,
|
||||
}) ?? {};
|
||||
const addNumber = useMutation(api.myFunctions.addNumber);
|
||||
|
||||
if (viewer === undefined || numbers === undefined) {
|
||||
return (
|
||||
<div className='mx-auto'>
|
||||
<p>loading... (consider a loading skeleton)</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-8 max-w-lg mx-auto'>
|
||||
<p>Welcome {viewer ?? 'Anonymous'}!</p>
|
||||
<p>
|
||||
Click the button below and open this page in another window - this data
|
||||
is persisted in the Convex cloud database!
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
className='bg-foreground text-background text-sm px-4 py-2 rounded-md'
|
||||
onClick={() => {
|
||||
void addNumber({ value: Math.floor(Math.random() * 10) });
|
||||
}}
|
||||
>
|
||||
Add a random number
|
||||
</button>
|
||||
</p>
|
||||
<p>
|
||||
Numbers:{' '}
|
||||
{numbers?.length === 0
|
||||
? 'Click the button!'
|
||||
: (numbers?.join(', ') ?? '...')}
|
||||
</p>
|
||||
<p>
|
||||
Edit{' '}
|
||||
<code className='text-sm font-bold font-mono bg-slate-200 dark:bg-slate-800 px-1 py-0.5 rounded-md'>
|
||||
convex/myFunctions.ts
|
||||
</code>{' '}
|
||||
to change your backend
|
||||
</p>
|
||||
<p>
|
||||
Edit{' '}
|
||||
<code className='text-sm font-bold font-mono bg-slate-200 dark:bg-slate-800 px-1 py-0.5 rounded-md'>
|
||||
app/page.tsx
|
||||
</code>{' '}
|
||||
to change your frontend
|
||||
</p>
|
||||
<p>
|
||||
See the{' '}
|
||||
<Link href='/server' className='underline hover:no-underline'>
|
||||
/server route
|
||||
</Link>{' '}
|
||||
for an example of loading data in a server component
|
||||
</p>
|
||||
<div className='flex flex-col'>
|
||||
<p className='text-lg font-bold'>Useful resources:</p>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-col gap-2 w-1/2'>
|
||||
<ResourceCard
|
||||
title='Convex docs'
|
||||
description='Read comprehensive documentation for all Convex features.'
|
||||
href='https://docs.convex.dev/home'
|
||||
/>
|
||||
<ResourceCard
|
||||
title='Stack articles'
|
||||
description='Learn about best practices, use cases, and more from a growing
|
||||
collection of articles, videos, and walkthroughs.'
|
||||
href='https://www.typescriptlang.org/docs/handbook/2/basic-types.html'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 w-1/2'>
|
||||
<ResourceCard
|
||||
title='Templates'
|
||||
description='Browse our collection of templates to get started quickly.'
|
||||
href='https://www.convex.dev/templates'
|
||||
/>
|
||||
<ResourceCard
|
||||
title='Discord'
|
||||
description='Join our developer community to ask questions, trade tips & tricks,
|
||||
and show off your projects.'
|
||||
href='https://www.convex.dev/community'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className='flex flex-col gap-2 bg-slate-200 dark:bg-slate-800 p-4 rounded-md h-28 overflow-auto'>
|
||||
<a href={href} className='text-sm underline hover:no-underline'>
|
||||
{title}
|
||||
</a>
|
||||
<p className='text-xs'>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
export default Home;
|
||||
|
@@ -1,31 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { type Preloaded, useMutation, usePreloadedQuery } from 'convex/react';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
|
||||
export default function Home({
|
||||
preloaded,
|
||||
}: {
|
||||
preloaded: Preloaded<typeof api.myFunctions.listNumbers>;
|
||||
}) {
|
||||
const data = usePreloadedQuery(preloaded);
|
||||
const addNumber = useMutation(api.myFunctions.addNumber);
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col gap-4 bg-slate-200 dark:bg-slate-800 p-4 rounded-md'>
|
||||
<h2 className='text-xl font-bold'>Reactive client-loaded data</h2>
|
||||
<code>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
className='bg-foreground text-background px-4 py-2 rounded-md mx-auto'
|
||||
onClick={() => {
|
||||
void addNumber({ value: Math.floor(Math.random() * 10) });
|
||||
}}
|
||||
>
|
||||
Add a random number
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import Home from './inner';
|
||||
import { preloadQuery, preloadedQueryResult } from 'convex/nextjs';
|
||||
import { api } from '~/convex/_generated/api';
|
||||
|
||||
export default async function ServerPage() {
|
||||
const preloaded = await preloadQuery(api.myFunctions.listNumbers, {
|
||||
count: 3,
|
||||
});
|
||||
|
||||
const data = preloadedQueryResult(preloaded);
|
||||
|
||||
return (
|
||||
<main className='p-8 flex flex-col gap-4 mx-auto max-w-2xl'>
|
||||
<h1 className='text-4xl font-bold text-center'>Convex + Next.js</h1>
|
||||
<div className='flex flex-col gap-4 bg-slate-200 dark:bg-slate-800 p-4 rounded-md'>
|
||||
<h2 className='text-xl font-bold'>Non-reactive server-loaded data</h2>
|
||||
<code>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
</code>
|
||||
</div>
|
||||
<Home preloaded={preloaded} />
|
||||
</main>
|
||||
);
|
||||
}
|
72
src/components/layout/header/controls/AvatarDropdown.tsx
Normal file
72
src/components/layout/header/controls/AvatarDropdown.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
BasedAvatar,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui';
|
||||
import { useConvexAuth, useQuery } from 'convex/react';
|
||||
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 user = useQuery(api.auth.getUser);
|
||||
|
||||
if (isLoading) return <BasedAvatar className='animate-pulse' />;
|
||||
if (!isAuthenticated) return <div/>;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<BasedAvatar
|
||||
src={user?.image}
|
||||
fullName={user?.name}
|
||||
className='lg:h-10 lg:w-10 my-auto'
|
||||
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>
|
||||
<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'
|
||||
variant='ghost'
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
23
src/components/layout/header/controls/index.tsx
Normal file
23
src/components/layout/header/controls/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'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>
|
||||
);
|
||||
};
|
69
src/components/layout/header/index.tsx
Normal file
69
src/components/layout/header/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'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 (
|
||||
<div className='absolute top-10 right-37'>
|
||||
<Controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
{...headerProps}
|
||||
className={cn(
|
||||
'w-full min-h-[10vh] 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'>
|
||||
<div className='sm:w-[120px] md:w-[160px]' />
|
||||
</div>
|
||||
|
||||
{/* 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='max-w-[40px] md:max-w-[120px]'
|
||||
/>
|
||||
<h1
|
||||
className='title-text text-sm 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;
|
166
src/components/providers/TVModeProvider.tsx
Normal file
166
src/components/providers/TVModeProvider.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'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 };
|
@@ -1,2 +1,3 @@
|
||||
export { ConvexClientProvider } from './ConvexClientProvider';
|
||||
export { ThemeProvider, ThemeToggle, type ThemeToggleProps } from './ThemeProvider';
|
||||
export { TVModeProvider, useTVMode, TVToggle } from './TVModeProvider';
|
||||
|
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
69
src/components/ui/based-avatar.tsx
Normal file
69
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 };
|
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
@@ -1,3 +1,5 @@
|
||||
export { Avatar, AvatarImage, AvatarFallback } from './avatar';
|
||||
export { BasedAvatar } from './based-avatar';
|
||||
export { Button, buttonVariants } from './button';
|
||||
export {
|
||||
Card,
|
||||
@@ -8,6 +10,14 @@ export {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from './card';
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from './dropdown-menu';
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
@@ -29,3 +39,4 @@ export {
|
||||
TabsTrigger,
|
||||
TabsContent
|
||||
} from './tabs';
|
||||
export { Toaster } from './sonner';
|
||||
|
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
Reference in New Issue
Block a user