From 43acc20a40decfeaa48df17a4ff86ce0b14bd7df Mon Sep 17 00:00:00 2001 From: Gib Date: Tue, 17 Jun 2025 13:06:35 -0500 Subject: [PATCH] Rewriting status card to make it look good and to go over the code --- .../(auth-pages)/forgot-password/layout.tsx | 14 +- src/app/(auth-pages)/profile/layout.tsx | 14 +- src/app/(auth-pages)/sign-in/layout.tsx | 14 +- src/app/(auth-pages)/sign-up/layout.tsx | 14 +- src/app/layout.tsx | 1 - src/app/status/list/layout.tsx | 14 +- src/app/status/list/page.tsx | 2 +- src/app/status/table/layout.tsx | 15 +- src/components/context/Auth.tsx | 1 - src/components/context/Query.tsx | 73 ++-- .../default/auth/buttons/SignInWithApple.tsx | 10 +- .../auth/buttons/SignInWithMicrosoft.tsx | 10 +- src/components/default/footer/index.tsx | 7 +- .../default/profile/AvatarUpload.tsx | 5 +- src/components/status/List.tsx | 369 ++++++++++++++++++ src/components/status/StatusList.tsx | 272 +++++++------ src/components/status/TechTable.tsx | 262 +++++++------ src/components/ui/avatar.tsx | 12 +- src/components/ui/loading.tsx | 2 +- src/lib/actions/public.ts | 8 +- src/lib/actions/status.ts | 70 ++-- src/lib/hooks/public.ts | 8 +- src/lib/hooks/status.ts | 72 ++-- src/middleware.ts | 8 +- src/utils/supabase/types.ts | 224 +++++------ 25 files changed, 981 insertions(+), 520 deletions(-) create mode 100755 src/components/status/List.tsx diff --git a/src/app/(auth-pages)/forgot-password/layout.tsx b/src/app/(auth-pages)/forgot-password/layout.tsx index 98acc0f..9268213 100644 --- a/src/app/(auth-pages)/forgot-password/layout.tsx +++ b/src/app/(auth-pages)/forgot-password/layout.tsx @@ -2,15 +2,13 @@ import type { Metadata } from 'next'; export const generateMetadata = (): Metadata => { return { - title: 'Forgot Password' + title: 'Forgot Password', }; }; -const ForgotPasswordLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { - return ( -
- {children} -
- ); - }; +const ForgotPasswordLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; export default ForgotPasswordLayout; diff --git a/src/app/(auth-pages)/profile/layout.tsx b/src/app/(auth-pages)/profile/layout.tsx index a4623b4..6dc208d 100644 --- a/src/app/(auth-pages)/profile/layout.tsx +++ b/src/app/(auth-pages)/profile/layout.tsx @@ -2,15 +2,13 @@ import type { Metadata } from 'next'; export const generateMetadata = (): Metadata => { return { - title: 'Profile' + title: 'Profile', }; }; -const ProfileLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { - return ( -
- {children} -
- ); - }; +const ProfileLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; export default ProfileLayout; diff --git a/src/app/(auth-pages)/sign-in/layout.tsx b/src/app/(auth-pages)/sign-in/layout.tsx index 89bd917..0fd1202 100644 --- a/src/app/(auth-pages)/sign-in/layout.tsx +++ b/src/app/(auth-pages)/sign-in/layout.tsx @@ -2,15 +2,13 @@ import type { Metadata } from 'next'; export const generateMetadata = (): Metadata => { return { - title: 'Sign In' + title: 'Sign In', }; }; -const SignInLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { - return ( -
- {children} -
- ); - }; +const SignInLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; export default SignInLayout; diff --git a/src/app/(auth-pages)/sign-up/layout.tsx b/src/app/(auth-pages)/sign-up/layout.tsx index bc0280e..a175094 100644 --- a/src/app/(auth-pages)/sign-up/layout.tsx +++ b/src/app/(auth-pages)/sign-up/layout.tsx @@ -2,15 +2,13 @@ import type { Metadata } from 'next'; export const generateMetadata = (): Metadata => { return { - title: 'Sign Up' + title: 'Sign Up', }; }; -const SignUpLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { - return ( -
- {children} -
- ); - }; +const SignUpLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; export default SignUpLayout; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 225c19d..f56e11d 100755 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -386,7 +386,6 @@ const geist = Geist({ variable: '--font-geist-sans', }); - const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( diff --git a/src/app/status/list/layout.tsx b/src/app/status/list/layout.tsx index ead8b51..0c92db7 100644 --- a/src/app/status/list/layout.tsx +++ b/src/app/status/list/layout.tsx @@ -2,15 +2,13 @@ import type { Metadata } from 'next'; export const generateMetadata = (): Metadata => { return { - title: 'Status List' + title: 'Status List', }; }; -const SignInLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { - return ( -
- {children} -
- ); - }; +const SignInLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; export default SignInLayout; diff --git a/src/app/status/list/page.tsx b/src/app/status/list/page.tsx index eaf66fd..a3878e2 100644 --- a/src/app/status/list/page.tsx +++ b/src/app/status/list/page.tsx @@ -1,6 +1,6 @@ 'use server'; -import { StatusList } from '@/components/status'; +import { StatusList } from '@/components/status/List'; import { getUser, getRecentUsersWithStatuses } from '@/lib/actions'; import { redirect } from 'next/navigation'; diff --git a/src/app/status/table/layout.tsx b/src/app/status/table/layout.tsx index 1b8b059..b3c3eaa 100644 --- a/src/app/status/table/layout.tsx +++ b/src/app/status/table/layout.tsx @@ -1,17 +1,14 @@ - import type { Metadata } from 'next'; export const generateMetadata = (): Metadata => { return { - title: 'Status Table' + title: 'Status Table', }; }; -const SignInLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { - return ( -
- {children} -
- ); - }; +const SignInLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; export default SignInLayout; diff --git a/src/components/context/Auth.tsx b/src/components/context/Auth.tsx index c3175c1..af94963 100644 --- a/src/components/context/Auth.tsx +++ b/src/components/context/Auth.tsx @@ -92,7 +92,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const { data: { subscription }, } = supabase.auth.onAuthStateChange(async (event, _session) => { - console.log('Auth state change:', event); // Debug log if (event === 'SIGNED_IN') { // Background refresh without loading state diff --git a/src/components/context/Query.tsx b/src/components/context/Query.tsx index ae4d7c5..d344f2a 100644 --- a/src/components/context/Query.tsx +++ b/src/components/context/Query.tsx @@ -1,7 +1,12 @@ // src/components/providers/query-provider.tsx 'use client'; -import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'; +import { + QueryClient, + QueryClientProvider, + QueryCache, + MutationCache, +} from '@tanstack/react-query'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -13,7 +18,8 @@ export const enum QueryErrorCodes { } const queryCacheOnError = (error: unknown, query: any) => { - const errorMessage = error instanceof Error ? error.message : 'Something went wrong'; + const errorMessage = + error instanceof Error ? error.message : 'Something went wrong'; switch (query.meta?.errCode) { case QueryErrorCodes.USERS_FETCH_FAILED: @@ -26,8 +32,14 @@ const queryCacheOnError = (error: unknown, query: any) => { } }; -const mutationCacheOnError = (error: unknown, variables: unknown, context: unknown, mutation: any) => { - const errorMessage = error instanceof Error ? error.message : 'Something went wrong'; +const mutationCacheOnError = ( + error: unknown, + variables: unknown, + context: unknown, + mutation: any, +) => { + const errorMessage = + error instanceof Error ? error.message : 'Something went wrong'; switch (mutation.meta?.errCode) { case QueryErrorCodes.STATUS_UPDATE_FAILED: @@ -44,34 +56,35 @@ type QueryProviderProps = { }; export const QueryProvider = ({ children }: QueryProviderProps) => { - const [queryClient] = useState(() => new QueryClient({ - queryCache: new QueryCache({ - onError: queryCacheOnError, - }), - mutationCache: new MutationCache({ - onError: mutationCacheOnError, - }), - defaultOptions: { - queries: { - staleTime: 30 * 1000, // 30 seconds - refetchOnWindowFocus: true, - retry: (failureCount, error) => { - // Don't retry on 4xx errors - if (error instanceof Error && error.message.includes('4')) { - return false; - } - return failureCount < 3; + const [queryClient] = useState( + () => + new QueryClient({ + queryCache: new QueryCache({ + onError: queryCacheOnError, + }), + mutationCache: new MutationCache({ + onError: mutationCacheOnError, + }), + defaultOptions: { + queries: { + staleTime: 30 * 1000, // 30 seconds + refetchOnWindowFocus: true, + retry: (failureCount, error) => { + // Don't retry on 4xx errors + if (error instanceof Error && error.message.includes('4')) { + return false; + } + return failureCount < 3; + }, + }, + mutations: { + retry: 1, + }, }, - }, - mutations: { - retry: 1, - }, - }, - })); + }), + ); return ( - - {children} - + {children} ); }; diff --git a/src/components/default/auth/buttons/SignInWithApple.tsx b/src/components/default/auth/buttons/SignInWithApple.tsx index 7c3cc1c..b9e416b 100644 --- a/src/components/default/auth/buttons/SignInWithApple.tsx +++ b/src/components/default/auth/buttons/SignInWithApple.tsx @@ -40,13 +40,15 @@ export const SignInWithApple = ({ if (!profile.provider) { const updateResponse = await updateProfile({ provider: result.data.provider, - }) - if (!updateResponse.success) throw new Error('Could not update provider!'); + }); + if (!updateResponse.success) + throw new Error('Could not update provider!'); } else { const updateResponse = await updateProfile({ provider: profile.provider + ' ' + result.data.provider, - }) - if (!updateResponse.success) throw new Error('Could not update provider!'); + }); + if (!updateResponse.success) + throw new Error('Could not update provider!'); } } // Redirect to Apple OAuth page diff --git a/src/components/default/auth/buttons/SignInWithMicrosoft.tsx b/src/components/default/auth/buttons/SignInWithMicrosoft.tsx index a35bb90..3626972 100644 --- a/src/components/default/auth/buttons/SignInWithMicrosoft.tsx +++ b/src/components/default/auth/buttons/SignInWithMicrosoft.tsx @@ -39,13 +39,15 @@ export const SignInWithMicrosoft = ({ if (!profile.provider) { const updateResponse = await updateProfile({ provider: result.data.provider, - }) - if (!updateResponse.success) throw new Error('Could not update provider!'); + }); + if (!updateResponse.success) + throw new Error('Could not update provider!'); } else { const updateResponse = await updateProfile({ provider: profile.provider + ' ' + result.data.provider, - }) - if (!updateResponse.success) throw new Error('Could not update provider!'); + }); + if (!updateResponse.success) + throw new Error('Could not update provider!'); } } window.location.href = result.data.url; diff --git a/src/components/default/footer/index.tsx b/src/components/default/footer/index.tsx index 2a9fd38..0f17b8c 100644 --- a/src/components/default/footer/index.tsx +++ b/src/components/default/footer/index.tsx @@ -14,7 +14,12 @@ const Footer = () => { hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F] flex items-center gap-2 transition-all duration-200' > - Gitea + Gitea View Source Code on Gitea diff --git a/src/components/default/profile/AvatarUpload.tsx b/src/components/default/profile/AvatarUpload.tsx index 14f46ff..b5d8bfd 100644 --- a/src/components/default/profile/AvatarUpload.tsx +++ b/src/components/default/profile/AvatarUpload.tsx @@ -1,9 +1,6 @@ import { useFileUpload } from '@/lib/hooks/useFileUpload'; import { useAuth } from '@/components/context'; -import { - BasedAvatar, - CardContent, -} from '@/components/ui'; +import { BasedAvatar, CardContent } from '@/components/ui'; import { Loader2, Pencil, Upload } from 'lucide-react'; type AvatarUploadProps = { diff --git a/src/components/status/List.tsx b/src/components/status/List.tsx new file mode 100755 index 0000000..45a4b57 --- /dev/null +++ b/src/components/status/List.tsx @@ -0,0 +1,369 @@ +'use client'; +import { useState, useEffect, useRef } from 'react'; +import { useAuth, useTVMode } from '@/components/context'; +import { + getRecentUsersWithStatuses, + updateStatuses, + updateUserStatus, + type UserWithStatus, +} from '@/lib/hooks'; +import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui'; +import { StatusMessage, SubmitButton } from '@/components/default'; +import { toast } from 'sonner'; +import { HistoryDrawer } from '@/components/status'; +import type { Profile } from '@/utils/supabase'; +import { makeConditionalClassName } from '@/lib/utils'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, Clock, Wifi, WifiOff } from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryErrorCodes } from '@/components/context'; +import { createClient } from '@/utils/supabase'; +import type { RealtimeChannel } from '@supabase/supabase-js'; + +type ListProps = { initialStatuses: UserWithStatus[] }; + +export const StatusList = ({ initialStatuses = [] }: ListProps) => { + const { isAuthenticated } = useAuth(); + const { tvMode } = useTVMode(); + const queryClient = useQueryClient(); + + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [statusInput, setStatusInput] = useState(''); + const [selectedHistoryUser, setSelectedHistoryUser] = + useState(null); + const [updateStatusMessage, setUpdateStatusMessage] = useState(''); + + const [connectionStatus, setConnectionStatus] = useState< + 'connecting' | 'connected' | 'disconnected' | 'updating' + >('connecting'); + const [newStatuses, setNewStatuses] = useState>(new Set()); + const channelRef = useRef(null); + const supabaseRef = useRef(createClient()); + + const { + data: usersWithStatuses = initialStatuses, + isLoading: loading, + error, + refetch, + isFetching, + dataUpdatedAt, + } = useQuery({ + queryKey: ['users-with-statuses'], + queryFn: async () => { + try { + const response = await getRecentUsersWithStatuses(); + if (!response.success) throw new Error(response.error); + return response.data; + } catch (error) { + toast.error(`Error fetching technicians: ${error as Error}`) + throw error; + } + }, + enabled: isAuthenticated, + refetchInterval: 30000, // 30 Second interval as we should rely on subscription. + refetchOnWindowFocus: true, + refetchOnMount: true, + initialData: initialStatuses, + meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED }, + }); + + useEffect(() => { + if (!isAuthenticated) return; + + const maxReconnectAttempts = 3; + let reconnectAttempts = 0; + let reconnectTimeout: NodeJS.Timeout; + let isComponentMounted = true; + let currentChannel: RealtimeChannel | null = null; + + const setUpRealtimeConnection = () => { + if (!isComponentMounted) return; + + if (currentChannel) { + supabaseRef.current.removeChannel(currentChannel) + .catch((error) => { + setConnectionStatus('disconnected'); + console.error(`Error unsubscribing: ${error}`); + }) + currentChannel = null; + } + + setConnectionStatus('connecting'); + const channel = supabaseRef.current + .channel('status_updates') + .on('broadcast', { event: 'status_updated' }, () => { + refetch().catch((error) => { + console.error(`Error refetching statuses: ${error as Error}`) + }); + }) + .subscribe((status) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (status === 'SUBSCRIBED') { + setConnectionStatus('connected'); + reconnectAttempts = 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === 'CHANNEL_ERROR') { + setConnectionStatus('disconnected'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === 'CLOSED') { + setConnectionStatus('disconnected') + if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + const delay = 2000 * reconnectAttempts; + console.log( + `Reconnecting after close. + ${reconnectAttempts} attempts of + ${maxReconnectAttempts} in ${delay}ms + `); + if (reconnectTimeout) clearTimeout(reconnectTimeout); + reconnectTimeout = setTimeout(() => { + if (isComponentMounted) setUpRealtimeConnection(); + }, delay); + } else { + console.warn('Max reconnection attempts reached or component is not mounted.'); + setConnectionStatus('disconnected'); + } + } + }); + currentChannel = channel; + channelRef.current = channel; + }; + + const initialTimeout = setTimeout(() => { + if (isComponentMounted) setUpRealtimeConnection(); + }, 1000); + + return () => { + isComponentMounted = false; + if (initialTimeout) clearTimeout(initialTimeout); + if (reconnectTimeout) clearTimeout(reconnectTimeout); + if (currentChannel) { + supabaseRef.current.removeChannel(currentChannel) + .catch((error) => { + console.error(`Error unsubscribing: ${error as Error}`) + }); + channelRef.current = null; + } + setConnectionStatus('disconnected'); + } + }, [isAuthenticated, refetch]); + + const updateStatusMutation = useMutation({ + + mutationFn: async ({ + usersWithStatuses, + status, + }: { + usersWithStatuses: UserWithStatus[]; + status: string; + }) => { + const previousConnectionStatus = connectionStatus; + try { + if (usersWithStatuses.length <= 0) { + const result = await updateUserStatus(status); + if (!result.success) throw new Error(result.error); + return result.data; + } else { + const result = await updateStatuses(usersWithStatuses, status); + if (!result.success) throw new Error(result.error); + return result.data; + } + } catch (error) { + console.error(`Error updating statuses: ${error as Error}`); + } + }, + meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED }, + + onMutate: async ({ usersWithStatuses, status }) => { + // Optimistic update logic + await queryClient.cancelQueries({ queryKey: ['users-with-statuses']} ); + const previousData = queryClient.getQueryData([ + 'users-with-statuses', + ]); + if (previousData && usersWithStatuses.length > 0) { + const now = new Date().toISOString(); + const optimisticData = previousData.map((userStatus) => { + if (usersWithStatuses.some((selected) => selected.user.id === userStatus.user.id)) + return { ...userStatus, status, created_at: now}; + return userStatus; + }); + queryClient.setQueryData(['users-with-statuses'], optimisticData); + + // Add animation to optimisticly updated statuses + const updatedStatuses = usersWithStatuses; + setNewStatuses((prev) => new Set([...prev, ...updatedStatuses])) + setTimeout(() => { + setNewStatuses((prev) => { + const updated = new Set(prev); + updatedStatuses.forEach((updatedStatus) => updated.delete(updatedStatus)); + return updated; + }); + }, 1000) + } + return { previousData }; + }, + + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['users-with-statuses']} ) + .catch((error) => console.error(`Error invalidating query: ${error}`)); + if (!data) return; + data.forEach((statusUpdate) => { + toast.success(` + ${statusUpdate.user.full_name}'s status updated to '${statusUpdate.status}'. + `); + }); + setSelectedUsers([]); + setStatusInput(''); + }, + + onError: (error, _variables, context) => { + if (context?.previousData) + queryClient.setQueryData(['users-with-statuses'], context.previousData); + toast.error(`Error updating statuses: ${error}`); + }, + }); + + const handleUpdateStatus = () => { + if (!isAuthenticated) { + setUpdateStatusMessage('You must be signed in to update technican statuses!') + return; + } else if (!statusInput.trim()) { + setUpdateStatusMessage('Your status must be in between 3 & 80 characters long!'); + return; + } + updateStatusMutation.mutate({ + usersWithStatuses: selectedUsers, + status: statusInput.trim(), + }); + }; + + + const handleCheckboxChange = (user: UserWithStatus) => { + setSelectedUsers((prev) => + prev.some((u) => u.user.id === user.user.id) + ? prev.filter((prevUser) => prevUser.user.id !== user.user.id) + : [...prev, user], + ); + }; + + const handleSelectAllChange = () => { + if (selectAll) { + setSelectedUsers([]); + } else { + setSelectedUsers(usersWithStatuses); + } + setSelectAll(!selectAll); + }; + + useEffect(() => { + setSelectAll( + selectedUsers.length === usersWithStatuses.length && + usersWithStatuses.length > 0, + ); + }, [selectedUsers.length, usersWithStatuses.length]); + + const getConnectionIcon = () => { + switch (connectionStatus) { + case 'connected': + return ; + case 'connecting': + return ; + case 'disconnected': + return ; + case 'updating': + return ; + } + }; + + const getConnectionText = () => { + switch (connectionStatus) { + case 'connected': + return 'Connected'; + case 'connecting': + return 'Connecting...'; + case 'disconnected': + return 'Disconnected'; + case 'updating': + return 'Updating...'; + } + }; + + const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: 'numeric', + }); + const day = date.getDate(); + const month = date.toLocaleString('default', { month: 'long' }); + return `${time} - ${month} ${day}`; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Error loading status updates

+ +
+ ); + } + + const containerClassName = makeConditionalClassName({ + context: tvMode, + defaultClassName: 'mx-auto space-y-4', + on: 'lg:w-11/12 w-full mt-15', + off: 'w-5/6', + }); + + const headerClassName = makeConditionalClassName({ + context: tvMode, + defaultClassName: '', + on: 'none', + off: 'flex items-center justify-between mb-4', + }) + + const cardClassName = makeConditionalClassName({ + context: tvMode, + defaultClassName: 'transition-all duration-300 hover:shadow-md', + on: 'lg:text-4xl', + off: 'lg:text-base', + }); + + return ( +
+
+
+ + + + {getConnectionIcon()} + {getConnectionText()} + +
+
+
+ ); + +}; diff --git a/src/components/status/StatusList.tsx b/src/components/status/StatusList.tsx index 5561197..1bb4cb4 100644 --- a/src/components/status/StatusList.tsx +++ b/src/components/status/StatusList.tsx @@ -1,29 +1,35 @@ -"use client" -import { useState, useEffect, useRef } from "react" -import { useAuth, useTVMode } from "@/components/context" -import { getRecentUsersWithStatuses, updateStatuses, updateUserStatus, type UserWithStatus } from "@/lib/hooks" -import { BasedAvatar, Drawer, DrawerTrigger, Loading } from "@/components/ui" -import { SubmitButton } from "@/components/default" -import { toast } from "sonner" -import { HistoryDrawer } from "@/components/status" -import type { Profile } from "@/utils/supabase" -import { makeConditionalClassName } from "@/lib/utils" -import { Card, CardContent, CardHeader } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { Button } from "@/components/ui/button" -import { RefreshCw, Clock, Wifi, WifiOff } from "lucide-react" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { QueryErrorCodes } from "@/components/context"; -import { createClient } from "@/utils/supabase"; +'use client'; +import { useState, useEffect, useRef } from 'react'; +import { useAuth, useTVMode } from '@/components/context'; +import { + getRecentUsersWithStatuses, + updateStatuses, + updateUserStatus, + type UserWithStatus, +} from '@/lib/hooks'; +import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui'; +import { SubmitButton } from '@/components/default'; +import { toast } from 'sonner'; +import { HistoryDrawer } from '@/components/status'; +import type { Profile } from '@/utils/supabase'; +import { makeConditionalClassName } from '@/lib/utils'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, Clock, Wifi, WifiOff } from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryErrorCodes } from '@/components/context'; +import { createClient } from '@/utils/supabase'; import type { RealtimeChannel } from '@supabase/supabase-js'; type StatusListProps = { - initialStatuses: UserWithStatus[] -} + initialStatuses: UserWithStatus[]; +}; -export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fixed props destructuring +export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { + // Fixed props destructuring const { isAuthenticated } = useAuth(); const { tvMode } = useTVMode(); const queryClient = useQueryClient(); @@ -31,14 +37,16 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi const [selectedUsers, setSelectedUsers] = useState([]); const [selectAll, setSelectAll] = useState(false); const [statusInput, setStatusInput] = useState(''); - const [selectedHistoryUser, setSelectedHistoryUser] = useState(null); + const [selectedHistoryUser, setSelectedHistoryUser] = + useState(null); - const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting") + const [connectionStatus, setConnectionStatus] = useState< + 'connecting' | 'connected' | 'disconnected' + >('connecting'); const [newStatusIds, setNewStatusIds] = useState>(new Set()); const channelRef = useRef(null); const supabaseRef = useRef(createClient()); - // Keep all your existing React Query code exactly as is const { data: usersWithStatuses = initialStatuses, @@ -55,7 +63,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi if (!response.success) throw new Error(response.error); return response.data; } catch (error) { - toast.error(`Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`); + toast.error( + `Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); throw error; } }, @@ -71,14 +81,14 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi useEffect(() => { if (!isAuthenticated) return; - let reconnectAttempts = 0 - const maxReconnectAttempts = 3 - let reconnectTimeout: NodeJS.Timeout - let isComponentMounted = true + let reconnectAttempts = 0; + const maxReconnectAttempts = 3; + let reconnectTimeout: NodeJS.Timeout; + let isComponentMounted = true; let currentChannel: RealtimeChannel | null = null; const setUpRealtimeConnection = () => { - if (!isComponentMounted) return + if (!isComponentMounted) return; if (currentChannel) { supabaseRef.current.removeChannel(currentChannel).catch((error) => { console.error(`Error unsubscribing: ${error}`); @@ -87,63 +97,68 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi } setConnectionStatus('connecting'); const channel = supabaseRef.current - .channel('status_updates') - .on('broadcast', { event: 'status_updated' }, (payload) => { - console.log('Realtime update received, triggering refetch...'); - refetch().catch((error) => { - console.error(`Error refetching: ${error}`); - }); - }) - .subscribe((status) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (status === 'SUBSCRIBED') { - console.log('Realtime connection established'); - setConnectionStatus('connected'); - reconnectAttempts = 0 + .channel('status_updates') + .on('broadcast', { event: 'status_updated' }, (payload) => { + console.log('Realtime update received, triggering refetch...'); + refetch().catch((error) => { + console.error(`Error refetching: ${error}`); + }); + }) + .subscribe((status) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - } else if (status === 'CHANNEL_ERROR') { - console.log('Realtime connection failed, relying on polling'); - setConnectionStatus('disconnected'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - } else if (status === 'CLOSED') { - console.log('Realtime connection closed'); - setConnectionStatus('disconnected'); - if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) { - reconnectAttempts++ - const delay = 2000 * reconnectAttempts + if (status === 'SUBSCRIBED') { + console.log('Realtime connection established'); + setConnectionStatus('connected'); + reconnectAttempts = 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === 'CHANNEL_ERROR') { + console.log('Realtime connection failed, relying on polling'); + setConnectionStatus('disconnected'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === 'CLOSED') { + console.log('Realtime connection closed'); + setConnectionStatus('disconnected'); + if ( + isComponentMounted && + reconnectAttempts < maxReconnectAttempts + ) { + reconnectAttempts++; + const delay = 2000 * reconnectAttempts; - console.log(`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`) + console.log( + `Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`, + ); - if (reconnectTimeout) { - clearTimeout(reconnectTimeout) - } - - reconnectTimeout = setTimeout(() => { - if (isComponentMounted) { - setUpRealtimeConnection() + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); } - }, delay) - } else { - console.log("Max reconnection attempts reached or component unmounted") + + reconnectTimeout = setTimeout(() => { + if (isComponentMounted) { + setUpRealtimeConnection(); + } + }, delay); + } else { + console.log( + 'Max reconnection attempts reached or component unmounted', + ); + } } - } - }); + }); currentChannel = channel; channelRef.current = channel; - } + }; const initialTimeout = setTimeout(() => { if (isComponentMounted) { - setUpRealtimeConnection() + setUpRealtimeConnection(); } - }, 1000) + }, 1000); return () => { - isComponentMounted = false - if (initialTimeout) - clearTimeout(initialTimeout) - if (reconnectTimeout) - clearTimeout(reconnectTimeout) + isComponentMounted = false; + if (initialTimeout) clearTimeout(initialTimeout); + if (reconnectTimeout) clearTimeout(reconnectTimeout); if (currentChannel) { console.log('Cleaning up realtime connection...'); @@ -157,7 +172,13 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi // Updated mutation const updateStatusMutation = useMutation({ - mutationFn: async ({ usersWithStatuses, status }: { usersWithStatuses: UserWithStatus[], status: string }) => { + mutationFn: async ({ + usersWithStatuses, + status, + }: { + usersWithStatuses: UserWithStatus[]; + status: string; + }) => { if (usersWithStatuses.length === 0) { const result = await updateUserStatus(status); if (!result.success) throw new Error(result.error); @@ -172,12 +193,18 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi onMutate: async ({ usersWithStatuses, status }) => { // Optimistic update logic await queryClient.cancelQueries({ queryKey: ['users-with-statuses'] }); - const previousData = queryClient.getQueryData(['users-with-statuses']); + const previousData = queryClient.getQueryData([ + 'users-with-statuses', + ]); if (previousData && usersWithStatuses.length > 0) { const now = new Date().toISOString(); - const optimisticData = previousData.map(userStatus => { - if (usersWithStatuses.some(selected => selected.user.id === userStatus.user.id)) { + const optimisticData = previousData.map((userStatus) => { + if ( + usersWithStatuses.some( + (selected) => selected.user.id === userStatus.user.id, + ) + ) { return { ...userStatus, status, @@ -189,14 +216,14 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi queryClient.setQueryData(['users-with-statuses'], optimisticData); // Add animation for optimistic updates - const updatedIds = usersWithStatuses.map(u => u.user.id); - setNewStatusIds(prev => new Set([...prev, ...updatedIds])); + const updatedIds = usersWithStatuses.map((u) => u.user.id); + setNewStatusIds((prev) => new Set([...prev, ...updatedIds])); // Remove animation after 1 second setTimeout(() => { - setNewStatusIds(prev => { + setNewStatusIds((prev) => { const updated = new Set(prev); - updatedIds.forEach(id => updated.delete(id)); + updatedIds.forEach((id) => updated.delete(id)); return updated; }); }, 1000); @@ -217,7 +244,8 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi setSelectedUsers([]); setStatusInput(''); }, - onError: (error, _variables, context) => { // Fixed unused variables + onError: (error, _variables, context) => { + // Fixed unused variables // Rollback optimistic update if (context?.previousData) { queryClient.setQueryData(['users-with-statuses'], context.previousData); @@ -239,15 +267,15 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi updateStatusMutation.mutate({ usersWithStatuses: selectedUsers, - status: statusInput.trim() + status: statusInput.trim(), }); }; const handleCheckboxChange = (user: UserWithStatus) => { setSelectedUsers((prev) => - prev.some(u => u.user.id === user.user.id) + prev.some((u) => u.user.id === user.user.id) ? prev.filter((prevUser) => prevUser.user.id !== user.user.id) - : [...prev, user] + : [...prev, user], ); }; @@ -263,31 +291,31 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi useEffect(() => { setSelectAll( selectedUsers.length === usersWithStatuses.length && - usersWithStatuses.length > 0 + usersWithStatuses.length > 0, ); }, [selectedUsers.length, usersWithStatuses.length]); const getConnectionIcon = () => { switch (connectionStatus) { - case "connected": - return - case "connecting": - return - case "disconnected": - return + case 'connected': + return ; + case 'connecting': + return ; + case 'disconnected': + return ; } - } + }; const getConnectionText = () => { switch (connectionStatus) { - case "connected": - return "Connected" - case "connecting": - return "Connecting..." - case "disconnected": - return "Disconnected" + case 'connected': + return 'Connected'; + case 'connecting': + return 'Connecting...'; + case 'disconnected': + return 'Disconnected'; } - } + }; const formatTime = (timestamp: string) => { const date = new Date(timestamp); @@ -343,12 +371,12 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi Tech Status {isFetching ? ( - + Updating... ) : ( - + {getConnectionIcon()} {getConnectionText()} @@ -371,9 +399,12 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi {/* Status Cards */}
{usersWithStatuses.map((userWithStatus) => { - const isSelected = selectedUsers.some(u => u.user.id === userWithStatus.user.id); + const isSelected = selectedUsers.some( + (u) => u.user.id === userWithStatus.user.id, + ); const isNewStatus = newStatusIds.has(userWithStatus.user.id); - const isUpdatedByOther = userWithStatus.updated_by && + const isUpdatedByOther = + userWithStatus.updated_by && userWithStatus.updated_by.id !== userWithStatus.user.id; return ( @@ -392,7 +423,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi {!tvMode && ( handleCheckboxChange(userWithStatus)} + onCheckedChange={() => + handleCheckboxChange(userWithStatus) + } onClick={(e) => e.stopPropagation()} /> )} @@ -402,7 +435,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi className={tvMode ? 'w-16 h-16' : 'w-12 h-12'} />
-

+

{userWithStatus.user.full_name ?? 'Unknown User'}

{isUpdatedByOther && ( @@ -413,7 +448,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi className='w-3 h-3' /> {userWithStatus.updated_by && ( - + Updated by {userWithStatus.updated_by.full_name} )} @@ -438,7 +475,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi transition-colors cursor-pointer text-left ${tvMode ? 'text-4xl' : 'text-base'} `} - onClick={() => setSelectedHistoryUser(userWithStatus.user)} + onClick={() => + setSelectedHistoryUser(userWithStatus.user) + } >

{userWithStatus.status}

@@ -455,7 +494,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi {usersWithStatuses.length === 0 && ( -

+

No status updates yet

@@ -475,7 +516,11 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi value={statusInput} onChange={(e) => setStatusInput(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && !updateStatusMutation.isPending) { + if ( + e.key === 'Enter' && + !e.shiftKey && + !updateStatusMutation.isPending + ) { e.preventDefault(); handleUpdateStatus(); } @@ -491,8 +536,7 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi ? 'Updating...' : selectedUsers.length > 0 ? `Update ${selectedUsers.length} Users` - : 'Update Status' - } + : 'Update Status'}
{selectedUsers.length > 0 && ( diff --git a/src/components/status/TechTable.tsx b/src/components/status/TechTable.tsx index 39e07e9..877d244 100755 --- a/src/components/status/TechTable.tsx +++ b/src/components/status/TechTable.tsx @@ -7,12 +7,7 @@ import { updateUserStatus, type UserWithStatus, } from '@/lib/hooks'; -import { - BasedAvatar, - Drawer, - DrawerTrigger, - Loading -} from '@/components/ui'; +import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui'; import { SubmitButton } from '@/components/default'; import { toast } from 'sonner'; import { HistoryDrawer } from '@/components/status'; @@ -30,17 +25,18 @@ type TechTableProps = { initialStatuses: UserWithStatus[]; }; -export const TechTable = ({ - initialStatuses = [], -}: TechTableProps) => { +export const TechTable = ({ initialStatuses = [] }: TechTableProps) => { const { isAuthenticated } = useAuth(); const { tvMode } = useTVMode(); const queryClient = useQueryClient(); const [selectedUsers, setSelectedUsers] = useState([]); const [selectAll, setSelectAll] = useState(false); const [statusInput, setStatusInput] = useState(''); - const [selectedHistoryUser, setSelectedHistoryUser] = useState(null); - const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting"); + const [selectedHistoryUser, setSelectedHistoryUser] = + useState(null); + const [connectionStatus, setConnectionStatus] = useState< + 'connecting' | 'connected' | 'disconnected' + >('connecting'); const [newStatusIds, setNewStatusIds] = useState>(new Set()); const channelRef = useRef(null); const supabaseRef = useRef(createClient()); @@ -61,7 +57,9 @@ export const TechTable = ({ if (!response.success) throw new Error(response.error); return response.data; } catch (error) { - toast.error(`Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`); + toast.error( + `Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); throw error; } }, @@ -76,14 +74,14 @@ export const TechTable = ({ // Add this new useEffect for realtime enhancement useEffect(() => { if (!isAuthenticated) return; - let reconnectAttempts = 0 - const maxReconnectAttempts = 3 - let reconnectTimeout: NodeJS.Timeout - let isComponentMounted = true + let reconnectAttempts = 0; + const maxReconnectAttempts = 3; + let reconnectTimeout: NodeJS.Timeout; + let isComponentMounted = true; let currentChannel: RealtimeChannel | null = null; const setUpRealtimeConnection = () => { - if (!isComponentMounted) return + if (!isComponentMounted) return; if (currentChannel) { supabaseRef.current.removeChannel(currentChannel).catch((error) => { console.error(`Error unsubscribing: ${error}`); @@ -92,60 +90,65 @@ export const TechTable = ({ } setConnectionStatus('connecting'); const channel = supabaseRef.current - .channel('status_updates') - .on('broadcast', { event: 'status_updated' }, (payload) => { - console.log('Realtime update received, triggering refetch...'); - refetch().catch((error) => { - console.error(`Error refetching: ${error}`); - }); - }) - .subscribe((status) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (status === 'SUBSCRIBED') { - console.log('Realtime connection established'); - setConnectionStatus('connected'); - reconnectAttempts = 0 + .channel('status_updates') + .on('broadcast', { event: 'status_updated' }, (payload) => { + console.log('Realtime update received, triggering refetch...'); + refetch().catch((error) => { + console.error(`Error refetching: ${error}`); + }); + }) + .subscribe((status) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - } else if (status === 'CHANNEL_ERROR') { - console.log('Realtime connection failed, relying on polling'); - setConnectionStatus('disconnected'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - } else if (status === 'CLOSED') { - console.log('Realtime connection closed'); - setConnectionStatus('disconnected'); - if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) { - reconnectAttempts++ - const delay = 2000 * reconnectAttempts - console.log(`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`) - if (reconnectTimeout) { - clearTimeout(reconnectTimeout) - } - reconnectTimeout = setTimeout(() => { - if (isComponentMounted) { - setUpRealtimeConnection() + if (status === 'SUBSCRIBED') { + console.log('Realtime connection established'); + setConnectionStatus('connected'); + reconnectAttempts = 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === 'CHANNEL_ERROR') { + console.log('Realtime connection failed, relying on polling'); + setConnectionStatus('disconnected'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === 'CLOSED') { + console.log('Realtime connection closed'); + setConnectionStatus('disconnected'); + if ( + isComponentMounted && + reconnectAttempts < maxReconnectAttempts + ) { + reconnectAttempts++; + const delay = 2000 * reconnectAttempts; + console.log( + `Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`, + ); + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); } - }, delay) - } else { - console.log("Max reconnection attempts reached or component unmounted") + reconnectTimeout = setTimeout(() => { + if (isComponentMounted) { + setUpRealtimeConnection(); + } + }, delay); + } else { + console.log( + 'Max reconnection attempts reached or component unmounted', + ); + } } - } - }); + }); currentChannel = channel; channelRef.current = channel; - } + }; const initialTimeout = setTimeout(() => { if (isComponentMounted) { - setUpRealtimeConnection() + setUpRealtimeConnection(); } - }, 1000) + }, 1000); return () => { - isComponentMounted = false - if (initialTimeout) - clearTimeout(initialTimeout) - if (reconnectTimeout) - clearTimeout(reconnectTimeout) + isComponentMounted = false; + if (initialTimeout) clearTimeout(initialTimeout); + if (reconnectTimeout) clearTimeout(reconnectTimeout); if (currentChannel) { console.log('Cleaning up realtime connection...'); supabaseRef.current.removeChannel(currentChannel).catch((error) => { @@ -158,7 +161,13 @@ export const TechTable = ({ // Updated mutation const updateStatusMutation = useMutation({ - mutationFn: async ({ usersWithStatuses, status }: { usersWithStatuses: UserWithStatus[], status: string }) => { + mutationFn: async ({ + usersWithStatuses, + status, + }: { + usersWithStatuses: UserWithStatus[]; + status: string; + }) => { if (usersWithStatuses.length === 0) { const result = await updateUserStatus(status); if (!result.success) throw new Error(result.error); @@ -173,11 +182,17 @@ export const TechTable = ({ onMutate: async ({ usersWithStatuses, status }) => { // Optimistic update logic await queryClient.cancelQueries({ queryKey: ['users-with-statuses'] }); - const previousData = queryClient.getQueryData(['users-with-statuses']); + const previousData = queryClient.getQueryData([ + 'users-with-statuses', + ]); if (previousData && usersWithStatuses.length > 0) { const now = new Date().toISOString(); - const optimisticData = previousData.map(userStatus => { - if (usersWithStatuses.some(selected => selected.user.id === userStatus.user.id)) { + const optimisticData = previousData.map((userStatus) => { + if ( + usersWithStatuses.some( + (selected) => selected.user.id === userStatus.user.id, + ) + ) { return { ...userStatus, status, @@ -188,13 +203,13 @@ export const TechTable = ({ }); queryClient.setQueryData(['users-with-statuses'], optimisticData); // Add animation for optimistic updates - const updatedIds = usersWithStatuses.map(u => u.user.id); - setNewStatusIds(prev => new Set([...prev, ...updatedIds])); + const updatedIds = usersWithStatuses.map((u) => u.user.id); + setNewStatusIds((prev) => new Set([...prev, ...updatedIds])); // Remove animation after 1 second setTimeout(() => { - setNewStatusIds(prev => { + setNewStatusIds((prev) => { const updated = new Set(prev); - updatedIds.forEach(id => updated.delete(id)); + updatedIds.forEach((id) => updated.delete(id)); return updated; }); }, 1000); @@ -212,7 +227,8 @@ export const TechTable = ({ setSelectedUsers([]); setStatusInput(''); }, - onError: (error, _variables, context) => { // Fixed unused variables + onError: (error, _variables, context) => { + // Fixed unused variables // Rollback optimistic update if (context?.previousData) { queryClient.setQueryData(['users-with-statuses'], context.previousData); @@ -233,15 +249,15 @@ export const TechTable = ({ } updateStatusMutation.mutate({ usersWithStatuses: selectedUsers, - status: statusInput.trim() + status: statusInput.trim(), }); }; const handleCheckboxChange = (user: UserWithStatus) => { setSelectedUsers((prev) => - prev.some(u => u.user.id === user.user.id) + prev.some((u) => u.user.id === user.user.id) ? prev.filter((prevUser) => prevUser.user.id !== user.user.id) - : [...prev, user] + : [...prev, user], ); }; @@ -257,31 +273,31 @@ export const TechTable = ({ useEffect(() => { setSelectAll( selectedUsers.length === usersWithStatuses.length && - usersWithStatuses.length > 0 + usersWithStatuses.length > 0, ); }, [selectedUsers.length, usersWithStatuses.length]); const getConnectionIcon = () => { switch (connectionStatus) { - case "connected": - return - case "connecting": - return - case "disconnected": - return + case 'connected': + return ; + case 'connecting': + return ; + case 'disconnected': + return ; } - } + }; const getConnectionText = () => { switch (connectionStatus) { - case "connected": - return "Connected" - case "connecting": - return "Connecting..." - case "disconnected": - return "Disconnected" + case 'connected': + return 'Connected'; + case 'connecting': + return 'Connecting...'; + case 'disconnected': + return 'Disconnected'; } - } + }; const formatTime = (timestamp: string) => { const date = new Date(timestamp); @@ -339,17 +355,17 @@ export const TechTable = ({
{/* Status Header */}
- {isFetching ? ( - - - Updating... - - ) : ( - - {getConnectionIcon()} - {getConnectionText()} - - )} + {isFetching ? ( + + + Updating... + + ) : ( + + {getConnectionIcon()} + {getConnectionText()} + + )}
@@ -368,9 +384,7 @@ export const TechTable = ({ {usersWithStatuses.map((userWithStatus, index) => { - const isSelected = selectedUsers.some(u => u.user.id === userWithStatus.user.id); + const isSelected = selectedUsers.some( + (u) => u.user.id === userWithStatus.user.id, + ); const isNewStatus = newStatusIds.has(userWithStatus.user.id); return ( @@ -412,22 +428,27 @@ export const TechTable = ({ className={tvMode ? 'w-16 h-16' : 'w-12 h-12'} />
-

+

{userWithStatus.user.full_name ?? 'Unknown User'}

{userWithStatus.updated_by && - userWithStatus.updated_by.id !== userWithStatus.user.id && ( -
- - - Updated by {userWithStatus.updated_by.full_name} - -
- )} + userWithStatus.updated_by.id !== + userWithStatus.user.id && ( +
+ + + Updated by {userWithStatus.updated_by.full_name} + +
+ )}
@@ -435,7 +456,9 @@ export const TechTable = ({ setSelectedHistoryUser(userWithStatus.user)} + onClick={() => + setSelectedHistoryUser(userWithStatus.user) + } > {userWithStatus.status} @@ -455,7 +478,9 @@ export const TechTable = ({ {usersWithStatuses.length === 0 && (
-

+

No status updates yet

@@ -497,8 +522,7 @@ export const TechTable = ({ ? 'Updating...' : selectedUsers.length > 0 ? `Update ${selectedUsers.length} Users` - : 'Update Status' - } + : 'Update Status'} )} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 39ae5e5..b5945a2 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -5,7 +5,6 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar'; import { User } from 'lucide-react'; import { cn } from '@/lib/utils'; - type BasedAvatarProps = React.ComponentProps & { src?: string | null; fullName?: string | null; @@ -17,7 +16,7 @@ type BasedAvatarProps = React.ComponentProps & { function BasedAvatar({ src = null, fullName = null, - imageClassName ='', + imageClassName = '', fallbackClassName = '', userIconSize = 32, className, @@ -33,10 +32,7 @@ function BasedAvatar({ {...props} > {src ? ( - + ) : ( ) { +}: React.ComponentProps< + typeof AvatarPrimitive.Fallback & { fullName: string } +>) { return ( = ({ }, [intervalMs, alpha]); return ( -
+
); diff --git a/src/lib/actions/public.ts b/src/lib/actions/public.ts index 07d90af..bcf8a9d 100644 --- a/src/lib/actions/public.ts +++ b/src/lib/actions/public.ts @@ -10,8 +10,7 @@ export const getProfile = async ( try { if (userId == null) { const user = await getUser(); - if (!user.success || !user.data.id) - throw new Error('User not found'); + if (!user.success || !user.data.id) throw new Error('User not found'); userId = user.data.id; } const supabase = await createServerClient(); @@ -35,7 +34,7 @@ export const getProfile = async ( }; export const getProfileWithAvatar = async ( - userId: string | null = null + userId: string | null = null, ): Promise> => { try { if (userId === null) { @@ -93,7 +92,8 @@ export const updateProfile = async ({ email === undefined && avatar_url === undefined && provider === undefined - ) throw new Error('No profile data provided'); + ) + throw new Error('No profile data provided'); const userResponse = await getUser(); if (!userResponse.success || userResponse.data === undefined) diff --git a/src/lib/actions/status.ts b/src/lib/actions/status.ts index af6f1c7..ecf7d1c 100644 --- a/src/lib/actions/status.ts +++ b/src/lib/actions/status.ts @@ -40,12 +40,14 @@ export const getRecentUsersWithStatuses = async (): Promise< const { data, error } = (await supabase .from('statuses') - .select(` + .select( + ` user:profiles!user_id(*), status, created_at, updated_by:profiles!updated_by_id(*) - `) + `, + ) .gte('created_at', oneDayAgo.toISOString()) .order('created_at', { ascending: false })) as { data: UserWithStatus[]; @@ -66,11 +68,13 @@ export const getRecentUsersWithStatuses = async (): Promise< const filteredWithAvatars = new Array(); for (const userWithStatus of filtered) { if (userWithStatus.user.avatar_url) - userWithStatus.user.avatar_url = - await getAvatarUrl(userWithStatus.user.avatar_url); + userWithStatus.user.avatar_url = await getAvatarUrl( + userWithStatus.user.avatar_url, + ); if (userWithStatus.updated_by?.avatar_url) - userWithStatus.updated_by.avatar_url = - await getAvatarUrl(userWithStatus.updated_by?.avatar_url); + userWithStatus.updated_by.avatar_url = await getAvatarUrl( + userWithStatus.updated_by?.avatar_url, + ); filteredWithAvatars.push(userWithStatus); } @@ -119,27 +123,31 @@ export const updateStatuses = async ( if (!profileResponse.success) throw new Error('Not authenticated!'); const user = profileResponse.data; - const { - data: insertedStatuses, - error: insertedStatusesError - } = await supabase - .from('statuses') - .insert(usersWithStatuses.map((userWithStatus) => ({ - user_id: userWithStatus.user.id, - status, - updated_by_id: user.id, - }))) - .select(); + const { data: insertedStatuses, error: insertedStatusesError } = + await supabase + .from('statuses') + .insert( + usersWithStatuses.map((userWithStatus) => ({ + user_id: userWithStatus.user.id, + status, + updated_by_id: user.id, + })), + ) + .select(); - if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!'); + if (insertedStatusesError) throw new Error("Couldn't insert statuses!"); else if (insertedStatuses) { const createdAtFallback = new Date(Date.now()).toISOString(); - await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return { - user: s.user, - status: status, - created_at: insertedStatuses[i]?.created_at ?? createdAtFallback, - updated_by: user, - }})); + await broadcastStatusUpdates( + usersWithStatuses.map((s, i) => { + return { + user: s.user, + status: status, + created_at: insertedStatuses[i]?.created_at ?? createdAtFallback, + updated_by: user, + }; + }), + ); } return { success: true, data: undefined }; } catch (error) { @@ -171,12 +179,14 @@ export const updateUserStatus = async ( .single(); if (insertedStatusError) throw insertedStatusError as Error; - await broadcastStatusUpdates([{ - user: userProfile, - status: insertedStatus.status, - created_at: insertedStatus.created_at, - updated_by: userProfile, - }]); + await broadcastStatusUpdates([ + { + user: userProfile, + status: insertedStatus.status, + created_at: insertedStatus.created_at, + updated_by: userProfile, + }, + ]); return { success: true, data: undefined }; } catch (error) { diff --git a/src/lib/hooks/public.ts b/src/lib/hooks/public.ts index 2b9350c..05335ef 100644 --- a/src/lib/hooks/public.ts +++ b/src/lib/hooks/public.ts @@ -10,8 +10,7 @@ export const getProfile = async ( try { if (userId == null) { const user = await getUser(); - if (!user.success || !user.data.id) - throw new Error('User not found'); + if (!user.success || !user.data.id) throw new Error('User not found'); userId = user.data.id; } const supabase = createClient(); @@ -35,7 +34,7 @@ export const getProfile = async ( }; export const getProfileWithAvatar = async ( - userId: string | null = null + userId: string | null = null, ): Promise> => { try { if (userId === null) { @@ -93,7 +92,8 @@ export const updateProfile = async ({ email === undefined && avatar_url === undefined && provider === undefined - ) throw new Error('No profile data provided'); + ) + throw new Error('No profile data provided'); const userResponse = await getUser(); if (!userResponse.success || userResponse.data === undefined) diff --git a/src/lib/hooks/status.ts b/src/lib/hooks/status.ts index 7d48467..632d5cb 100644 --- a/src/lib/hooks/status.ts +++ b/src/lib/hooks/status.ts @@ -40,12 +40,14 @@ export const getRecentUsersWithStatuses = async (): Promise< const { data, error } = (await supabase .from('statuses') - .select(` + .select( + ` user:profiles!user_id(*), status, created_at, updated_by:profiles!updated_by_id(*) - `) + `, + ) .gte('created_at', oneDayAgo.toISOString()) .order('created_at', { ascending: false })) as { data: UserWithStatus[]; @@ -66,11 +68,13 @@ export const getRecentUsersWithStatuses = async (): Promise< const filteredWithAvatars = new Array(); for (const userWithStatus of filtered) { if (userWithStatus.user.avatar_url) - userWithStatus.user.avatar_url = - await getAvatarUrl(userWithStatus.user.avatar_url); + userWithStatus.user.avatar_url = await getAvatarUrl( + userWithStatus.user.avatar_url, + ); if (userWithStatus.updated_by?.avatar_url) - userWithStatus.updated_by.avatar_url = - await getAvatarUrl(userWithStatus.updated_by?.avatar_url); + userWithStatus.updated_by.avatar_url = await getAvatarUrl( + userWithStatus.updated_by?.avatar_url, + ); filteredWithAvatars.push(userWithStatus); } @@ -112,47 +116,52 @@ export const broadcastStatusUpdates = async ( export const updateStatuses = async ( usersWithStatuses: UserWithStatus[], status: string, -): Promise> => { +): Promise> => { try { const supabase = createClient(); const profileResponse = await getProfileWithAvatar(); if (!profileResponse.success) throw new Error('Not authenticated!'); const user = profileResponse.data; - const { - data: insertedStatuses, - error: insertedStatusesError - } = await supabase - .from('statuses') - .insert(usersWithStatuses.map((userWithStatus) => ({ - user_id: userWithStatus.user.id, - status, - updated_by_id: user.id, - }))) - .select(); + const { data: insertedStatuses, error: insertedStatusesError } = + await supabase + .from('statuses') + .insert( + usersWithStatuses.map((userWithStatus) => ({ + user_id: userWithStatus.user.id, + status, + updated_by_id: user.id, + })), + ) + .select(); - if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!'); + if (insertedStatusesError) throw new Error("Error inserting statuses!"); else if (insertedStatuses) { const createdAtFallback = new Date(Date.now()).toISOString(); - await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return { - user: s.user, - status: status, - created_at: insertedStatuses[i]?.created_at ?? createdAtFallback, - updated_by: user, - }})); + const statusUpdates = usersWithStatuses.map((s, i) => { + return { + user: s.user, + status: status, + created_at: insertedStatuses[i]?.created_at ?? createdAtFallback, + updated_by: user, + } + }); + await broadcastStatusUpdates(statusUpdates); + return { success: true, data: statusUpdates }; + } else { + return { success: false, error: 'No inserted statuses returned!' }; } - return { success: true, data: undefined }; } catch (error) { return { success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: `Error updating statuses: ${error as Error}`, }; } }; export const updateUserStatus = async ( status: string, -): Promise> => { +): Promise> => { try { const supabase = createClient(); const profileResponse = await getProfileWithAvatar(); @@ -171,14 +180,15 @@ export const updateUserStatus = async ( .single(); if (insertedStatusError) throw insertedStatusError as Error; - await broadcastStatusUpdates([{ + const statusUpdate = { user: userProfile, status: insertedStatus.status, created_at: insertedStatus.created_at, updated_by: userProfile, - }]); + }; + await broadcastStatusUpdates([statusUpdate]); - return { success: true, data: undefined }; + return { success: true, data: [statusUpdate] }; } catch (error) { return { success: false, diff --git a/src/middleware.ts b/src/middleware.ts index b91a634..6d9d3a4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,6 +4,8 @@ import { updateSession } from '@/utils/supabase/middleware'; // In-memory store for tracking IPs (use Redis in production) const ipAttempts = new Map(); const bannedIPs = new Set(); +// Ban Arctic Wolf Explicitly +bannedIPs.add('::ffff:10.0.1.49'); // Suspicious patterns that indicate malicious activity const MALICIOUS_PATTERNS = [ @@ -93,7 +95,7 @@ export const middleware = async (request: NextRequest) => { // Check if IP is already banned if (bannedIPs.has(ip)) { - console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`); + //console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`); return new NextResponse('Access denied.', { status: 403 }); } @@ -102,13 +104,13 @@ export const middleware = async (request: NextRequest) => { const isSuspiciousMethod = isMethodSuspicious(method); if (isSuspiciousPath || isSuspiciousMethod) { - console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`); + //console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`); const shouldBan = updateIPAttempts(ip); if (shouldBan) { console.log(`🔨 IP ${ip} has been banned for suspicious activity`); - return new NextResponse('Access denied - IP banned', { status: 403 }); + return new NextResponse('Access denied - IP banned. Please fuck off.', { status: 403 }); } // Return 404 to not reveal the blocking mechanism diff --git a/src/utils/supabase/types.ts b/src/utils/supabase/types.ts index 6bb7842..c114c75 100644 --- a/src/utils/supabase/types.ts +++ b/src/utils/supabase/types.ts @@ -4,200 +4,200 @@ export type Json = | boolean | null | { [key: string]: Json | undefined } - | Json[] + | Json[]; export type Database = { public: { Tables: { profiles: { Row: { - avatar_url: string | null - email: string | null - full_name: string | null - id: string - provider: string | null - updated_at: string | null - } + avatar_url: string | null; + email: string | null; + full_name: string | null; + id: string; + provider: string | null; + updated_at: string | null; + }; Insert: { - avatar_url?: string | null - email?: string | null - full_name?: string | null - id: string - provider?: string | null - updated_at?: string | null - } + avatar_url?: string | null; + email?: string | null; + full_name?: string | null; + id: string; + provider?: string | null; + updated_at?: string | null; + }; Update: { - avatar_url?: string | null - email?: string | null - full_name?: string | null - id?: string - provider?: string | null - updated_at?: string | null - } - Relationships: [] - } + avatar_url?: string | null; + email?: string | null; + full_name?: string | null; + id?: string; + provider?: string | null; + updated_at?: string | null; + }; + Relationships: []; + }; statuses: { Row: { - created_at: string - id: string - status: string - updated_by_id: string | null - user_id: string - } + created_at: string; + id: string; + status: string; + updated_by_id: string | null; + user_id: string; + }; Insert: { - created_at?: string - id?: string - status: string - updated_by_id?: string | null - user_id: string - } + created_at?: string; + id?: string; + status: string; + updated_by_id?: string | null; + user_id: string; + }; Update: { - created_at?: string - id?: string - status?: string - updated_by_id?: string | null - user_id?: string - } + created_at?: string; + id?: string; + status?: string; + updated_by_id?: string | null; + user_id?: string; + }; Relationships: [ { - foreignKeyName: "statuses_updated_by_id_fkey" - columns: ["updated_by_id"] - isOneToOne: false - referencedRelation: "profiles" - referencedColumns: ["id"] + foreignKeyName: 'statuses_updated_by_id_fkey'; + columns: ['updated_by_id']; + isOneToOne: false; + referencedRelation: 'profiles'; + referencedColumns: ['id']; }, { - foreignKeyName: "statuses_user_id_fkey" - columns: ["user_id"] - isOneToOne: false - referencedRelation: "profiles" - referencedColumns: ["id"] + foreignKeyName: 'statuses_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'profiles'; + referencedColumns: ['id']; }, - ] - } - } + ]; + }; + }; Views: { - [_ in never]: never - } + [_ in never]: never; + }; Functions: { - [_ in never]: never - } + [_ in never]: never; + }; Enums: { - [_ in never]: never - } + [_ in never]: never; + }; CompositeTypes: { - [_ in never]: never - } - } -} + [_ in never]: never; + }; + }; +}; -type DefaultSchema = Database[Extract] +type DefaultSchema = Database[Extract]; export type Tables< DefaultSchemaTableNameOrOptions extends - | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) | { schema: keyof Database }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof Database + schema: keyof Database; } - ? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + ? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views']) : never = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } - ? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R + ? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; } ? R : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & - DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & - DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & + DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & + DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; } ? R : never - : never + : never; export type TablesInsert< DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] + | keyof DefaultSchema['Tables'] | { schema: keyof Database }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof Database + schema: keyof Database; } - ? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] : never = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } - ? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; } ? I : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; } ? I : never - : never + : never; export type TablesUpdate< DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] + | keyof DefaultSchema['Tables'] | { schema: keyof Database }, TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof Database + schema: keyof Database; } - ? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] : never = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } - ? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; } ? U : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; } ? U : never - : never + : never; export type Enums< DefaultSchemaEnumNameOrOptions extends - | keyof DefaultSchema["Enums"] + | keyof DefaultSchema['Enums'] | { schema: keyof Database }, EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof Database + schema: keyof Database; } - ? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + ? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] : never = never, > = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database } - ? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] - : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] - ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never + ? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never; export type CompositeTypes< PublicCompositeTypeNameOrOptions extends - | keyof DefaultSchema["CompositeTypes"] + | keyof DefaultSchema['CompositeTypes'] | { schema: keyof Database }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof Database + schema: keyof Database; } - ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + ? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] : never = never, > = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } - ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] - ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never + ? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never; export const Constants = { public: { Enums: {}, }, -} as const +} as const;
Technician - + Status @@ -381,7 +395,9 @@ export const TechTable = ({