Rewriting status card to make it look good and to go over the code

This commit is contained in:
2025-06-17 13:06:35 -05:00
parent a28af1f629
commit 43acc20a40
25 changed files with 981 additions and 520 deletions

View File

@ -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 (
<div>
{children}
</div>
);
const ForgotPasswordLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default ForgotPasswordLayout;

View File

@ -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 (
<div>
{children}
</div>
);
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default ProfileLayout;

View File

@ -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 (
<div>
{children}
</div>
);
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignInLayout;

View File

@ -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 (
<div>
{children}
</div>
);
const SignUpLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignUpLayout;

View File

@ -386,7 +386,6 @@ const geist = Geist({
variable: '--font-geist-sans',
});
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>

View File

@ -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 (
<div>
{children}
</div>
);
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignInLayout;

View File

@ -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';

View File

@ -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 (
<div>
{children}
</div>
);
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignInLayout;

View File

@ -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

View File

@ -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,7 +56,9 @@ type QueryProviderProps = {
};
export const QueryProvider = ({ children }: QueryProviderProps) => {
const [queryClient] = useState(() => new QueryClient({
const [queryClient] = useState(
() =>
new QueryClient({
queryCache: new QueryCache({
onError: queryCacheOnError,
}),
@ -67,11 +81,10 @@ export const QueryProvider = ({ children }: QueryProviderProps) => {
retry: 1,
},
},
}));
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

View File

@ -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

View File

@ -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;

View File

@ -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'
>
<Image src='/icons/misc/gitea.svg' alt='Gitea' width={20} height={20} />
<Image
src='/icons/misc/gitea.svg'
alt='Gitea'
width={20}
height={20}
/>
<span className='text-white'>View Source Code on Gitea</span>
</Link>
</div>

View File

@ -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 = {

369
src/components/status/List.tsx Executable file
View File

@ -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<UserWithStatus[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null);
const [updateStatusMessage, setUpdateStatusMessage] = useState('');
const [connectionStatus, setConnectionStatus] = useState<
'connecting' | 'connected' | 'disconnected' | 'updating'
>('connecting');
const [newStatuses, setNewStatuses] = useState<Set<UserWithStatus>>(new Set());
const channelRef = useRef<RealtimeChannel | null>(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<UserWithStatus[]>([
'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 <Wifi className='w-4 h-4 text-green-500' />;
case 'connecting':
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
case 'disconnected':
return <WifiOff className='w-4 h-4 text-red-500' />;
case 'updating':
return <RefreshCw className='w-4 h-4 text-blue-500 animate-spin' />;
}
};
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 (
<div className='flex justify-center items-center min-h-[400px]'>
<Loading className='w-full' alpha={0.5} />
</div>
);
}
if (error) {
return (
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
<p className='text-red-500'>Error loading status updates</p>
<Button onClick={() => refetch()} variant='outline'>
<RefreshCw className='w-4 h-4 mr-2' />
Retry
</Button>
</div>
);
}
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 (
<div className={containerClassName}>
<div className={headerClassName}>
<div className='flex items-center gap-2'>
<Checkbox
id='select-all'
checked={selectAll}
onCheckedChange={handleSelectAllChange}
/>
<label htmlFor='select-all' className='text-sm font-medium'>
Select All
</label>
<Badge variant='outline' className='flex items-center gap-2'>
{getConnectionIcon()}
<span className='text-sm'>{getConnectionText()}</span>
</Badge>
</div>
</div>
</div>
);
};

View File

@ -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<UserWithStatus[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [selectedHistoryUser, setSelectedHistoryUser] = useState<Profile | null>(null);
const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null);
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting")
const [connectionStatus, setConnectionStatus] = useState<
'connecting' | 'connected' | 'disconnected'
>('connecting');
const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set());
const channelRef = useRef<RealtimeChannel | null>(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}`);
@ -99,7 +109,7 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi
if (status === 'SUBSCRIBED') {
console.log('Realtime connection established');
setConnectionStatus('connected');
reconnectAttempts = 0
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');
@ -108,42 +118,47 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi
} else if (status === 'CLOSED') {
console.log('Realtime connection closed');
setConnectionStatus('disconnected');
if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++
const delay = 2000 * reconnectAttempts
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)
clearTimeout(reconnectTimeout);
}
reconnectTimeout = setTimeout(() => {
if (isComponentMounted) {
setUpRealtimeConnection()
setUpRealtimeConnection();
}
}, delay)
}, delay);
} else {
console.log("Max reconnection attempts reached or component unmounted")
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<UserWithStatus[]>(['users-with-statuses']);
const previousData = queryClient.getQueryData<UserWithStatus[]>([
'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 <Wifi className="w-4 h-4 text-green-500" />
case "connecting":
return <Wifi className="w-4 h-4 text-yellow-500 animate-pulse" />
case "disconnected":
return <WifiOff className="w-4 h-4 text-red-500" />
}
case 'connected':
return <Wifi className='w-4 h-4 text-green-500' />;
case 'connecting':
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
case 'disconnected':
return <WifiOff className='w-4 h-4 text-red-500' />;
}
};
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
</h2>
{isFetching ? (
<Badge variant="outline" className="flex items-center gap-2">
<Badge variant='outline' className='flex items-center gap-2'>
<RefreshCw className='w-4 h-4 animate-spin text-blue-500' />
<span className='text-xs'>Updating...</span>
</Badge>
) : (
<Badge variant="outline" className="flex items-center gap-2">
<Badge variant='outline' className='flex items-center gap-2'>
{getConnectionIcon()}
<span className='text-xs'>{getConnectionText()}</span>
</Badge>
@ -371,9 +399,12 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi
{/* Status Cards */}
<div className='space-y-3'>
{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 && (
<Checkbox
checked={isSelected}
onCheckedChange={() => 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'}
/>
<div>
<h3 className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}>
<h3
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}
>
{userWithStatus.user.full_name ?? 'Unknown User'}
</h3>
{isUpdatedByOther && (
@ -413,7 +448,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi
className='w-3 h-3'
/>
{userWithStatus.updated_by && (
<span className={`text-xs ${tvMode ? 'text-3xl' : ''}`}>
<span
className={`text-xs ${tvMode ? 'text-3xl' : ''}`}
>
Updated by {userWithStatus.updated_by.full_name}
</span>
)}
@ -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)
}
>
<p className='font-medium'>{userWithStatus.status}</p>
</div>
@ -455,7 +494,9 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fi
{usersWithStatuses.length === 0 && (
<Card className='p-8 text-center'>
<p className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}>
<p
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
>
No status updates yet
</p>
</Card>
@ -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'}
</SubmitButton>
</div>
{selectedUsers.length > 0 && (

View File

@ -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<UserWithStatus[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [selectedHistoryUser, setSelectedHistoryUser] = useState<Profile | null>(null);
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting");
const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
'connecting' | 'connected' | 'disconnected'
>('connecting');
const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set());
const channelRef = useRef<RealtimeChannel | null>(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}`);
@ -104,7 +102,7 @@ export const TechTable = ({
if (status === 'SUBSCRIBED') {
console.log('Realtime connection established');
setConnectionStatus('connected');
reconnectAttempts = 0
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');
@ -113,39 +111,44 @@ export const TechTable = ({
} 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 (
isComponentMounted &&
reconnectAttempts < maxReconnectAttempts
) {
reconnectAttempts++;
const delay = 2000 * reconnectAttempts;
console.log(
`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`,
);
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
clearTimeout(reconnectTimeout);
}
reconnectTimeout = setTimeout(() => {
if (isComponentMounted) {
setUpRealtimeConnection()
setUpRealtimeConnection();
}
}, delay)
}, delay);
} else {
console.log("Max reconnection attempts reached or component unmounted")
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<UserWithStatus[]>(['users-with-statuses']);
const previousData = queryClient.getQueryData<UserWithStatus[]>([
'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 <Wifi className="w-4 h-4 text-green-500" />
case "connecting":
return <Wifi className="w-4 h-4 text-yellow-500 animate-pulse" />
case "disconnected":
return <WifiOff className="w-4 h-4 text-red-500" />
}
case 'connected':
return <Wifi className='w-4 h-4 text-green-500' />;
case 'connecting':
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
case 'disconnected':
return <WifiOff className='w-4 h-4 text-red-500' />;
}
};
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);
@ -340,12 +356,12 @@ export const TechTable = ({
{/* Status Header */}
<div className='flex items-center justify-between mb-6'>
{isFetching ? (
<Badge variant="outline" className="flex items-center gap-2">
<Badge variant='outline' className='flex items-center gap-2'>
<RefreshCw className='w-4 h-4 animate-spin text-blue-500' />
<span className='text-xs'>Updating...</span>
</Badge>
) : (
<Badge variant="outline" className="flex items-center gap-2">
<Badge variant='outline' className='flex items-center gap-2'>
{getConnectionIcon()}
<span className='text-xs'>{getConnectionText()}</span>
</Badge>
@ -368,9 +384,7 @@ export const TechTable = ({
<th className={thClassName}>Technician</th>
<th className={thClassName}>
<Drawer>
<DrawerTrigger
className='hover:text-foreground/60 cursor-pointer'
>
<DrawerTrigger className='hover:text-foreground/60 cursor-pointer'>
Status
</DrawerTrigger>
<HistoryDrawer />
@ -381,7 +395,9 @@ export const TechTable = ({
</thead>
<tbody>
{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,18 +428,23 @@ export const TechTable = ({
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
/>
<div>
<p className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}>
<p
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}
>
{userWithStatus.user.full_name ?? 'Unknown User'}
</p>
{userWithStatus.updated_by &&
userWithStatus.updated_by.id !== userWithStatus.user.id && (
userWithStatus.updated_by.id !==
userWithStatus.user.id && (
<div className='flex items-center gap-1 text-muted-foreground'>
<BasedAvatar
src={userWithStatus.updated_by?.avatar_url}
fullName={userWithStatus.updated_by?.full_name}
className='w-3 h-3'
/>
<span className={`text-xs ${tvMode ? 'text-3xl' : ''}`}>
<span
className={`text-xs ${tvMode ? 'text-3xl' : ''}`}
>
Updated by {userWithStatus.updated_by.full_name}
</span>
</div>
@ -435,7 +456,9 @@ export const TechTable = ({
<Drawer>
<DrawerTrigger
className='text-center w-full p-2 rounded-md hover:bg-muted transition-colors'
onClick={() => setSelectedHistoryUser(userWithStatus.user)}
onClick={() =>
setSelectedHistoryUser(userWithStatus.user)
}
>
{userWithStatus.status}
</DrawerTrigger>
@ -455,7 +478,9 @@ export const TechTable = ({
{usersWithStatuses.length === 0 && (
<div className='p-8 text-center'>
<p className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}>
<p
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
>
No status updates yet
</p>
</div>
@ -497,8 +522,7 @@ export const TechTable = ({
? 'Updating...'
: selectedUsers.length > 0
? `Update ${selectedUsers.length} Users`
: 'Update Status'
}
: 'Update Status'}
</SubmitButton>
</div>
)}

View File

@ -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<typeof AvatarPrimitive.Root> & {
src?: string | null;
fullName?: string | null;
@ -33,10 +32,7 @@ function BasedAvatar({
{...props}
>
{src ? (
<AvatarImage
src={src}
className={imageClassName}
/>
<AvatarImage src={src} className={imageClassName} />
) : (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
@ -92,7 +88,9 @@ function AvatarImage({
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback & {fullName: string}>) {
}: React.ComponentProps<
typeof AvatarPrimitive.Fallback & { fullName: string }
>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'

View File

@ -32,7 +32,7 @@ export const Loading: React.FC<Loading_Props> = ({
}, [intervalMs, alpha]);
return (
<div className="items-center justify-center w-1/3 m-auto pt-20">
<div className='items-center justify-center w-1/3 m-auto pt-20'>
<Progress value={progress} className={className} {...props} />
</div>
);

View File

@ -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<Result<Profile>> => {
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)

View File

@ -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<UserWithStatus>();
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
const { data: insertedStatuses, error: insertedStatusesError } =
await supabase
.from('statuses')
.insert(usersWithStatuses.map((userWithStatus) => ({
.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 {
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([{
await broadcastStatusUpdates([
{
user: userProfile,
status: insertedStatus.status,
created_at: insertedStatus.created_at,
updated_by: userProfile,
}]);
},
]);
return { success: true, data: undefined };
} catch (error) {

View File

@ -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<Result<Profile>> => {
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)

View File

@ -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<UserWithStatus>();
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<Result<void>> => {
): Promise<Result<UserWithStatus[]>> => {
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
const { data: insertedStatuses, error: insertedStatusesError } =
await supabase
.from('statuses')
.insert(usersWithStatuses.map((userWithStatus) => ({
.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 {
const statusUpdates = 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 };
});
await broadcastStatusUpdates(statusUpdates);
return { success: true, data: statusUpdates };
} else {
return { success: false, error: 'No inserted statuses returned!' };
}
} 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<Result<void>> => {
): Promise<Result<UserWithStatus[]>> => {
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,

View File

@ -4,6 +4,8 @@ import { updateSession } from '@/utils/supabase/middleware';
// In-memory store for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Ban Arctic Wolf Explicitly
bannedIPs.add('::ffff:10.0.1.49');
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
@ -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

View File

@ -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<keyof Database, "public">]
type DefaultSchema = Database[Extract<keyof Database, 'public'>];
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;