12 KiB

src/app/layout.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

'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

'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

'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

'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('/');
};