470 lines
12 KiB
Markdown
470 lines
12 KiB
Markdown
|
|
src/app/layout.tsx
|
|
```tsx
|
|
import '@/styles/globals.css';
|
|
import { GeistSans } from 'geist/font/sans';
|
|
import { cn } from '@/lib/utils';
|
|
import { ThemeProvider } from '@/components/context/Theme';
|
|
import { TVModeProvider } from '@/components/context/TVMode';
|
|
import { createClient } from '@/utils/supabase/server';
|
|
import LoginForm from '@/components/auth/LoginForm';
|
|
import Header from '@/components/defaults/Header';
|
|
|
|
import { type Metadata } from 'next';
|
|
export const metadata: Metadata = {
|
|
title: 'Tech Tracker',
|
|
description:
|
|
'App used by COG IT employees to \
|
|
update their status throughout the day.',
|
|
icons: [
|
|
{
|
|
rel: 'icon',
|
|
url: '/favicon.ico',
|
|
},
|
|
{
|
|
rel: 'icon',
|
|
type: 'image/png',
|
|
sizes: '32x32',
|
|
url: '/images/tech_tracker_favicon.png',
|
|
},
|
|
{
|
|
rel: 'apple-touch-icon',
|
|
url: '/imges/tech_tracker_appicon.png',
|
|
},
|
|
],
|
|
};
|
|
|
|
const RootLayout = async ({
|
|
children,
|
|
}: Readonly<{ children: React.ReactNode }>) => {
|
|
return (
|
|
<html
|
|
lang='en'
|
|
className={`${GeistSans.variable}`}
|
|
suppressHydrationWarning
|
|
>
|
|
<body className={cn('min-h-screen bg-background font-sans antialiased')}>
|
|
<ThemeProvider
|
|
attribute='class'
|
|
defaultTheme='system'
|
|
enableSystem
|
|
disableTransitionOnChange
|
|
>
|
|
<TVModeProvider>
|
|
<main className='min-h-screen'>
|
|
<Header />
|
|
{children}
|
|
</main>
|
|
</TVModeProvider>
|
|
</ThemeProvider>
|
|
</body>
|
|
</html>
|
|
);
|
|
};
|
|
export default RootLayout;
|
|
|
|
```
|
|
|
|
src/components/auth/AvatarDropdown.tsx
|
|
```tsx
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Image from 'next/image';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuLabel,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { createClient } from '@/utils/supabase/client';
|
|
import type { Session } from '@supabase/supabase-js';
|
|
import { Button } from '@/components/ui/button';
|
|
import type { User } from '@/lib/types';
|
|
import { getImageUrl } from '@/server/actions/image';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
const AvatarDropdown = () => {
|
|
const supabase = createClient();
|
|
const router = useRouter();
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [pfp, setPfp] = useState<string>('/images/default_user_pfp.png');
|
|
|
|
useEffect(() => {
|
|
// Function to fetch the session
|
|
async function fetchSession() {
|
|
try {
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession();
|
|
setSession(session);
|
|
|
|
if (session?.user?.id) {
|
|
const { data: userData, error } = await supabase
|
|
.from('profiles')
|
|
.select('*')
|
|
.eq('id', session?.user.id)
|
|
.single();
|
|
if (error) {
|
|
console.error('Error fetching user data:', error);
|
|
return;
|
|
}
|
|
if (userData) {
|
|
const user = userData as User;
|
|
console.log(user);
|
|
setUser(user);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching session:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Call the function
|
|
fetchSession().catch((error) => {
|
|
console.error('Error fetching session:', error);
|
|
});
|
|
|
|
// Set up auth state change listener
|
|
const {
|
|
data: { subscription },
|
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
|
setSession(session);
|
|
});
|
|
|
|
// Clean up the subscription when component unmounts
|
|
return () => {
|
|
subscription.unsubscribe();
|
|
};
|
|
}, [supabase]);
|
|
|
|
useEffect(() => {
|
|
const downloadImage = async (path: string) => {
|
|
try {
|
|
setLoading(true);
|
|
const url = await getImageUrl('avatars', path);
|
|
console.log(url);
|
|
setPfp(url);
|
|
} catch (error) {
|
|
console.error(
|
|
'Error downloading image:',
|
|
error instanceof Error ? error.message : error,
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
if (user?.avatar_url) {
|
|
try {
|
|
downloadImage(user.avatar_url).catch((error) => {
|
|
console.error('Error downloading image:', error);
|
|
});
|
|
} catch (error) {
|
|
console.error('Error: ', error);
|
|
}
|
|
}
|
|
}, [user, supabase]);
|
|
|
|
const getInitials = (fullName: string | undefined): string => {
|
|
if (!fullName || fullName.trim() === '' || fullName === 'undefined')
|
|
return 'NA';
|
|
const nameParts = fullName.trim().split(' ');
|
|
const firstInitial = nameParts[0]?.charAt(0).toUpperCase() ?? 'N';
|
|
if (nameParts.length === 1) return 'NA';
|
|
const lastIntitial =
|
|
nameParts[nameParts.length - 1]?.charAt(0).toUpperCase() ?? 'A';
|
|
return firstInitial + lastIntitial;
|
|
};
|
|
|
|
// Handle sign out
|
|
const handleSignOut = async () => {
|
|
await supabase.auth.signOut();
|
|
router.push('/');
|
|
};
|
|
|
|
// Show nothing while loading
|
|
if (loading) {
|
|
return <div className='animate-pulse h-8 w-8 rounded-full bg-gray-300' />;
|
|
}
|
|
|
|
// If no session, return empty div
|
|
if (!session) return <div />;
|
|
return (
|
|
<div className='m-auto mt-1'>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger>
|
|
<Image
|
|
src={pfp}
|
|
alt={getInitials(user?.full_name) ?? 'NA'}
|
|
width={40}
|
|
height={40}
|
|
className='rounded-full border-2
|
|
border-muted-foreground m-auto mr-1 md:mr-2
|
|
max-w-[35px] sm:max-w-[40px]'
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuLabel>{user?.full_name}</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<Button onClick={handleSignOut} className='w-full text-left'>
|
|
Sign Out
|
|
</Button>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
};
|
|
export default AvatarDropdown;
|
|
|
|
```
|
|
|
|
src/components/context/TVMode.tsx
|
|
```tsx
|
|
'use client';
|
|
import React, { createContext, useContext, useState } from 'react';
|
|
import Image from 'next/image';
|
|
import type { ReactNode } from 'react';
|
|
|
|
interface TVModeContextProps {
|
|
tvMode: boolean;
|
|
toggleTVMode: () => void;
|
|
}
|
|
|
|
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
|
|
|
|
export const TVModeProvider = ({ children }: { children: ReactNode }) => {
|
|
const [tvMode, setTVMode] = useState(false);
|
|
|
|
const toggleTVMode = () => {
|
|
setTVMode((prev) => !prev);
|
|
};
|
|
|
|
return (
|
|
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
|
|
{children}
|
|
</TVModeContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useTVMode = () => {
|
|
const context = useContext(TVModeContext);
|
|
if (!context) {
|
|
throw new Error('useTVMode must be used within a TVModeProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
type TVToggleProps = {
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
|
|
export const TVToggle = ({ width = 25, height = 25 }: TVToggleProps) => {
|
|
const { tvMode, toggleTVMode } = useTVMode();
|
|
return (
|
|
<button onClick={toggleTVMode} className='mr-4 mt-1'>
|
|
{tvMode ? (
|
|
<Image
|
|
src='/images/exit_fullscreen.svg'
|
|
alt='Exit TV Mode'
|
|
width={width}
|
|
height={height}
|
|
/>
|
|
) : (
|
|
<Image
|
|
src='/images/fullscreen.svg'
|
|
alt='Enter TV Mode'
|
|
width={width}
|
|
height={height}
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
```
|
|
|
|
src/components/defaults/Header.tsx
|
|
```tsx
|
|
'use client';
|
|
import { useState, useEffect } from 'react';
|
|
import Image from 'next/image';
|
|
import { TVToggle, useTVMode } from '@/components/context/TVMode';
|
|
import { ThemeToggle } from '@/components/context/Theme';
|
|
import AvatarDropdown from '@/components/auth/AvatarDropdown';
|
|
import { createClient } from '@/utils/supabase/client';
|
|
import type { Session } from '@supabase/supabase-js';
|
|
|
|
const Header = () => {
|
|
const { tvMode } = useTVMode();
|
|
|
|
const supabase = createClient();
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
// Function to fetch the session
|
|
async function fetchSession() {
|
|
try {
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession();
|
|
setSession(session);
|
|
} catch (error) {
|
|
console.error('Error fetching session:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Call the function
|
|
fetchSession().catch((error) => {
|
|
console.error('Error fetching session:', error);
|
|
});
|
|
|
|
// Set up auth state change listener
|
|
const {
|
|
data: { subscription },
|
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
|
setSession(session);
|
|
});
|
|
|
|
// Clean up the subscription when component unmounts
|
|
return () => {
|
|
subscription.unsubscribe();
|
|
};
|
|
}, [supabase]);
|
|
|
|
if (tvMode) {
|
|
return (
|
|
<div className='w-full flex flex-row items-end justify-end'>
|
|
<div
|
|
className='flex flex-row my-auto items-center
|
|
justify-center pt-2 pr-0 sm:pt-4 sm:pr-8'
|
|
>
|
|
<ThemeToggle />
|
|
{session && !loading && (
|
|
<div
|
|
className='flex flex-row my-auto items-center
|
|
justify-center'
|
|
>
|
|
<div className='mb-0.5 ml-4'>
|
|
<TVToggle width={22} height={22} />
|
|
</div>
|
|
<AvatarDropdown />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} else {
|
|
return (
|
|
<header className='w-full min-h-[10vh]'>
|
|
<div className='w-full flex flex-row items-end justify-end'>
|
|
<div
|
|
className='flex flex-row my-auto items-center
|
|
justify-center pt-2 pr-0 sm:pt-4 sm:pr-8'
|
|
>
|
|
<ThemeToggle />
|
|
{session && !loading && (
|
|
<div
|
|
className='flex flex-row my-auto items-center
|
|
justify-center'
|
|
>
|
|
<div className='mb-0.5 ml-4'>
|
|
<TVToggle width={22} height={22} />
|
|
</div>
|
|
<AvatarDropdown />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className='flex flex-row items-center text-center
|
|
justify-center'
|
|
>
|
|
<Image
|
|
src='/images/tech_tracker_logo.png'
|
|
alt='Tech Tracker Logo'
|
|
width={100}
|
|
height={100}
|
|
className='max-w-[40px] md:max-w-[120px]'
|
|
/>
|
|
<h1
|
|
className='title-text text-xl sm:text-4xl md:text-6xl lg:text-8xl
|
|
bg-gradient-to-r dark:from-[#bec8e6] dark:via-[#F0EEE4]
|
|
dark:to-[#FFF8E7] from-[#2e3266] via-slate-600 to-zinc-700
|
|
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
|
|
>
|
|
Tech Tracker
|
|
</h1>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
};
|
|
export default Header;
|
|
|
|
```
|
|
|
|
src/server/actions/auth.ts
|
|
```ts
|
|
'use server';
|
|
|
|
import 'server-only';
|
|
import { revalidatePath } from 'next/cache';
|
|
import { redirect } from 'next/navigation';
|
|
|
|
import { createClient } from '@/utils/supabase/server';
|
|
|
|
export const login = async (formData: FormData) => {
|
|
const supabase = await createClient();
|
|
|
|
// type-casting here for convenience
|
|
// in practice, you should validate your inputs
|
|
const data = {
|
|
email: formData.get('email') as string,
|
|
password: formData.get('password') as string,
|
|
};
|
|
|
|
const { error } = await supabase.auth.signInWithPassword(data);
|
|
|
|
if (error) {
|
|
redirect('/error');
|
|
}
|
|
|
|
revalidatePath('/', 'layout');
|
|
redirect('/');
|
|
};
|
|
|
|
export const signup = async (formData: FormData) => {
|
|
const supabase = await createClient();
|
|
|
|
// type-casting here for convenience
|
|
// in practice, you should validate your inputs
|
|
const data = {
|
|
fullName: formData.get('fullName') as string,
|
|
email: formData.get('email') as string,
|
|
password: formData.get('password') as string,
|
|
};
|
|
|
|
const { error } = await supabase.auth.signUp(data);
|
|
|
|
if (error) {
|
|
redirect('/error');
|
|
}
|
|
|
|
revalidatePath('/', 'layout');
|
|
redirect('/');
|
|
};
|
|
|
|
```
|