Compare commits
12 Commits
55fdeb23b3
...
main
Author | SHA1 | Date | |
---|---|---|---|
301a9acec0 | |||
dd0ba7f894 | |||
b7e8237dce | |||
43acc20a40 | |||
a28af1f629 | |||
bc915275cf | |||
6c85c973b9 | |||
d78c139ffb | |||
0e62bafa45 | |||
7e755535fe | |||
55e283731d | |||
0ba218e521 |
18
package.json
18
package.json
@ -27,10 +27,11 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@sentry/nextjs": "^9.29.0",
|
||||
"@sentry/nextjs": "^9.30.0",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.50.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.510.0",
|
||||
@ -39,26 +40,27 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
"sonner": "^2.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.64"
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@tanstack/eslint-plugin-query": "^5.78.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/node": "^20.19.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-prettier": "^5.5.0",
|
||||
"import-in-the-middle": "^1.14.2",
|
||||
"postcss": "^8.5.5",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.12",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@ -66,7 +68,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
"typescript-eslint": "^8.34.1"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
|
490
pnpm-lock.yaml
generated
490
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
/* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
|
||||
* This is especially useful for Docker builds.
|
||||
*/
|
||||
import './src/env.js';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import { withPlausibleProxy } from 'next-plausible';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
const config = withPlausibleProxy({
|
||||
customDomain: 'https://plausible.gbrown.org',
|
||||
})({
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@ -28,22 +30,29 @@ const config = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
//turbopack: {
|
||||
//rules: {
|
||||
//'*.svg': {
|
||||
//loaders: ['@svgr/webpack'],
|
||||
//as: '*.js',
|
||||
//},
|
||||
//},
|
||||
//},
|
||||
};
|
||||
turbopack: {
|
||||
rules: {
|
||||
'*.svg': {
|
||||
loaders: [
|
||||
{
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
as: '*.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sentryConfig = {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
org: 'gib',
|
||||
project: 't3-supabase-template',
|
||||
sentryUrl: process.env.SENTRY_URL,
|
||||
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -4,6 +4,7 @@ import { Geist } from 'next/font/google';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AuthProvider,
|
||||
QueryProvider,
|
||||
ThemeProvider,
|
||||
TVModeProvider,
|
||||
} from '@/components/context';
|
||||
@ -389,7 +390,8 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
return (
|
||||
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
||||
<body
|
||||
className={cn('bg-background text-foreground font-sans antialiased')}
|
||||
className={cn('bg-background text-foreground font-sans antialiased m-10\
|
||||
leading-relaxed px-10')}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
@ -397,6 +399,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<PlausibleProvider
|
||||
domain='techtracker.gbrown.org'
|
||||
@ -414,6 +417,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
</TVModeProvider>
|
||||
</PlausibleProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -8,22 +8,7 @@ const Home = async () => {
|
||||
if (!userResponse.success) {
|
||||
redirect('/sign-in');
|
||||
} else if (userResponse.data) {
|
||||
redirect('/status');
|
||||
redirect('/status/list');
|
||||
} else return <div />;
|
||||
};
|
||||
export default Home;
|
||||
|
||||
//'use client';
|
||||
|
||||
////import { TechTable } from '@/components/status';
|
||||
//import { redirect } from 'next/navigation';
|
||||
//import { useAuth } from '@/components/context';
|
||||
|
||||
//const HomePage = () => {
|
||||
//const { isAuthenticated } = useAuth();
|
||||
//if (!isAuthenticated) {
|
||||
//redirect('/sign-in');
|
||||
//}
|
||||
//redirect('/profile');
|
||||
//};
|
||||
//export default HomePage;
|
||||
|
@ -1,16 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Status Table'
|
||||
};
|
||||
};
|
||||
|
||||
const StatusLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default StatusLayout;
|
18
src/app/status/list/layout.tsx
Normal file
18
src/app/status/list/layout.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Status List',
|
||||
};
|
||||
};
|
||||
|
||||
const SignInLayout = ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
return (
|
||||
<div className=''>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SignInLayout;
|
18
src/app/status/list/page.tsx
Normal file
18
src/app/status/list/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use server';
|
||||
|
||||
import { StatusList } from '@/components/status';
|
||||
import { getUser, getRecentUsersWithStatuses } from '@/lib/actions';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
const Status = async () => {
|
||||
const userRespoonse = await getUser();
|
||||
if (!userRespoonse.success) {
|
||||
redirect('/sign-in');
|
||||
} else {
|
||||
const response = await getRecentUsersWithStatuses();
|
||||
if (!response.success) throw new Error(response.error);
|
||||
const usersWithStatuses = response.data;
|
||||
return <StatusList initialStatuses={usersWithStatuses} />;
|
||||
}
|
||||
};
|
||||
export default Status;
|
14
src/app/status/table/layout.tsx
Normal file
14
src/app/status/table/layout.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Status Table',
|
||||
};
|
||||
};
|
||||
|
||||
const SignInLayout = ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
export default SignInLayout;
|
@ -10,7 +10,7 @@ import React, {
|
||||
} from 'react';
|
||||
import {
|
||||
getProfile,
|
||||
getSignedUrl,
|
||||
getProfileWithAvatar,
|
||||
getUser,
|
||||
updateProfile as updateProfileAction,
|
||||
} from '@/lib/hooks';
|
||||
@ -20,7 +20,6 @@ import { toast } from 'sonner';
|
||||
type AuthContextType = {
|
||||
user: User | null;
|
||||
profile: Profile | null;
|
||||
avatarUrl: string | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
updateProfile: (data: {
|
||||
@ -36,7 +35,6 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const fetchingRef = useRef(false);
|
||||
@ -53,32 +51,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
|
||||
const userResponse = await getUser();
|
||||
const profileResponse = await getProfile();
|
||||
const profileResponse = await getProfileWithAvatar();
|
||||
|
||||
if (!userResponse.success || !profileResponse.success) {
|
||||
setUser(null);
|
||||
setProfile(null);
|
||||
setAvatarUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(userResponse.data);
|
||||
setProfile(profileResponse.data);
|
||||
|
||||
// Get avatar URL if available
|
||||
if (profileResponse.data.avatar_url) {
|
||||
const avatarResponse = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url: profileResponse.data.avatar_url,
|
||||
});
|
||||
if (avatarResponse.success) {
|
||||
setAvatarUrl(avatarResponse.data);
|
||||
} else {
|
||||
setAvatarUrl(null);
|
||||
}
|
||||
} else {
|
||||
setAvatarUrl(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Auth fetch error: ',
|
||||
@ -110,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
|
||||
@ -118,7 +99,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setUser(null);
|
||||
setProfile(null);
|
||||
setAvatarUrl(null);
|
||||
setIsLoading(false);
|
||||
} else if (event === 'TOKEN_REFRESHED') {
|
||||
// Silent refresh - don't show loading
|
||||
@ -158,7 +138,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setUser(null);
|
||||
setProfile(null);
|
||||
setAvatarUrl(null);
|
||||
setIsLoading(false);
|
||||
} else if (event === 'TOKEN_REFRESHED') {
|
||||
console.log('Token refreshed, updating user data');
|
||||
@ -184,18 +163,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
throw new Error(result.error ?? 'Failed to update profile');
|
||||
}
|
||||
setProfile(result.data);
|
||||
|
||||
// If avatar was updated, refresh the avatar URL
|
||||
if (data.avatar_url && result.data.avatar_url) {
|
||||
const avatarResponse = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url: result.data.avatar_url,
|
||||
transform: { width: 128, height: 128 },
|
||||
});
|
||||
if (avatarResponse.success) {
|
||||
setAvatarUrl(avatarResponse.data);
|
||||
}
|
||||
}
|
||||
toast.success('Profile updated successfully!');
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
@ -216,7 +183,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const value = {
|
||||
user,
|
||||
profile,
|
||||
avatarUrl,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
updateProfile,
|
||||
|
90
src/components/context/Query.tsx
Normal file
90
src/components/context/Query.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
// src/components/providers/query-provider.tsx
|
||||
'use client';
|
||||
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
QueryCache,
|
||||
MutationCache,
|
||||
} from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Define error codes for different types of errors
|
||||
export const enum QueryErrorCodes {
|
||||
USERS_FETCH_FAILED = 'USERS_FETCH_FAILED',
|
||||
STATUS_UPDATE_FAILED = 'STATUS_UPDATE_FAILED',
|
||||
// Add more as needed
|
||||
}
|
||||
|
||||
const queryCacheOnError = (error: unknown, query: any) => {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Something went wrong';
|
||||
|
||||
switch (query.meta?.errCode) {
|
||||
case QueryErrorCodes.USERS_FETCH_FAILED:
|
||||
// Don't show toast for user fetch errors - handle in component
|
||||
break;
|
||||
default:
|
||||
// Only show generic errors for unexpected failures
|
||||
console.error('Query error:', error);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
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:
|
||||
toast.error(`Failed to update status: ${errorMessage}`);
|
||||
break;
|
||||
default:
|
||||
toast.error(`Operation failed: ${errorMessage}`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
type QueryProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
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;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
export { AuthProvider, useAuth } from './Auth';
|
||||
export { ThemeProvider, ThemeToggle } from './Theme';
|
||||
export { TVModeProvider, useTVMode, TVToggle } from './TVMode';
|
||||
export * from './Query';
|
||||
|
@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { signInWithApple } from '@/lib/actions';
|
||||
import { signInWithApple, getProfile, updateProfile } from '@/lib/actions';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -34,8 +34,25 @@ export const SignInWithApple = ({
|
||||
const result = await signInWithApple();
|
||||
|
||||
if (result?.success && result.data) {
|
||||
const profileResponse = await getProfile();
|
||||
if (profileResponse.success) {
|
||||
const profile = profileResponse.data;
|
||||
if (!profile.provider) {
|
||||
const updateResponse = await updateProfile({
|
||||
provider: result.data.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!');
|
||||
}
|
||||
}
|
||||
// Redirect to Apple OAuth page
|
||||
window.location.href = result.data;
|
||||
window.location.href = result.data.url;
|
||||
} else {
|
||||
setStatusMessage(`Error signing in with Apple!`);
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import Image from 'next/image';
|
||||
import { type buttonVariants } from '@/components/ui';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import { getProfile, updateProfile } from '@/lib/hooks';
|
||||
|
||||
type SignInWithMicrosoftProps = {
|
||||
className?: ComponentProps<'div'>['className'];
|
||||
@ -32,8 +33,24 @@ export const SignInWithMicrosoft = ({
|
||||
const result = await signInWithMicrosoft();
|
||||
|
||||
if (result?.success && result.data) {
|
||||
// Redirect to Microsoft OAuth page
|
||||
window.location.href = result.data;
|
||||
const profileResponse = await getProfile();
|
||||
if (profileResponse.success) {
|
||||
const profile = profileResponse.data;
|
||||
if (!profile.provider) {
|
||||
const updateResponse = await updateProfile({
|
||||
provider: result.data.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!');
|
||||
}
|
||||
}
|
||||
window.location.href = result.data.url;
|
||||
} else {
|
||||
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -2,9 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
BasedAvatar,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@ -15,10 +13,9 @@ import {
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signOut } from '@/lib/actions';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const AvatarDropdown = () => {
|
||||
const { profile, avatarUrl, refreshUserData } = useAuth();
|
||||
const { profile, refreshUserData } = useAuth();
|
||||
const router = useRouter();
|
||||
const { toggleTVMode, tvMode } = useTVMode();
|
||||
|
||||
@ -30,36 +27,16 @@ const AvatarDropdown = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string | null | undefined): string => {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Avatar className='cursor-pointer scale-125'>
|
||||
{avatarUrl ? (
|
||||
<AvatarImage
|
||||
src={avatarUrl}
|
||||
alt={getInitials(profile?.full_name)}
|
||||
width={64}
|
||||
height={64}
|
||||
<BasedAvatar
|
||||
src={profile?.avatar_url}
|
||||
fullName={profile?.full_name}
|
||||
className='lg:h-12 lg:w-12 my-auto'
|
||||
fallbackClassName='text-xl font-semibold'
|
||||
userIconSize={32}
|
||||
/>
|
||||
) : (
|
||||
<AvatarFallback className='text-md'>
|
||||
{profile?.full_name ? (
|
||||
getInitials(profile.full_name)
|
||||
) : (
|
||||
<User size={64} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className='font-bold'>
|
||||
|
@ -1,5 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { ThemeToggle, useTVMode } from '@/components/context';
|
||||
@ -9,27 +8,38 @@ import AvatarDropdown from './AvatarDropdown';
|
||||
const Header = () => {
|
||||
const { tvMode } = useTVMode();
|
||||
const { isAuthenticated } = useAuth();
|
||||
return tvMode ? (
|
||||
<div className='w-full py-2 pt-6 md:py-5'>
|
||||
<div className='absolute top-8 right-24'>
|
||||
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
|
||||
{isAuthenticated && <AvatarDropdown />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<header className='w-full py-2 pt-6 md:py-5'>
|
||||
<div className='absolute top-8 right-16'>
|
||||
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4 md:pr-8'>
|
||||
|
||||
// Controls component for both modes
|
||||
const Controls = () => (
|
||||
<div className='flex flex-row items-center'>
|
||||
<ThemeToggle className='mr-4' />
|
||||
{isAuthenticated && <AvatarDropdown />}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tvMode) {
|
||||
return (
|
||||
<div className='absolute top-10 right-37'>
|
||||
<Controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className='w-full mb-8'>
|
||||
<div className='container mx-auto px-4 md:px-6 lg:px-20'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Left spacer for perfect centering */}
|
||||
<div className='flex flex-1 justify-start'>
|
||||
<div className='sm:w-[120px] md:w-[160px]' />
|
||||
</div>
|
||||
|
||||
{/* Centered logo and title */}
|
||||
<div className='flex-shrink-0'>
|
||||
<Link
|
||||
href='/'
|
||||
scroll={false}
|
||||
className='flex flex-row items-center text-center
|
||||
justify-center sm:ml-0 p-4 mt-10 sm:mt-0'
|
||||
className='flex flex-row items-center justify-center px-4'
|
||||
>
|
||||
<Image
|
||||
src='/favicon.png'
|
||||
@ -38,15 +48,24 @@ const Header = () => {
|
||||
height={100}
|
||||
className='max-w-[40px] md:max-w-[120px]'
|
||||
/>
|
||||
<h1
|
||||
className='title-text text-sm md:text-4xl lg:text-8xl
|
||||
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
|
||||
<h1 className='title-text text-sm md:text-4xl lg:text-8xl
|
||||
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
|
||||
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
|
||||
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
|
||||
>
|
||||
Tech Tracker
|
||||
</h1>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right-aligned controls */}
|
||||
<div className='flex-1 flex justify-end'>
|
||||
<Controls />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
@ -1,19 +1,14 @@
|
||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||
import { useAuth } from '@/components/context';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
CardContent,
|
||||
} from '@/components/ui';
|
||||
import { Loader2, Pencil, Upload, User } from 'lucide-react';
|
||||
import { BasedAvatar, CardContent } from '@/components/ui';
|
||||
import { Loader2, Pencil, Upload } from 'lucide-react';
|
||||
|
||||
type AvatarUploadProps = {
|
||||
onAvatarUploaded: (path: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||
const { profile, avatarUrl } = useAuth();
|
||||
const { profile } = useAuth();
|
||||
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
@ -40,15 +35,6 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string | null | undefined): string => {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<CardContent>
|
||||
<div className='flex flex-col items-center'>
|
||||
@ -56,24 +42,13 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||
className='relative group cursor-pointer mb-4'
|
||||
onClick={handleAvatarClick}
|
||||
>
|
||||
<Avatar className='h-32 w-32'>
|
||||
{avatarUrl ? (
|
||||
<AvatarImage
|
||||
src={avatarUrl}
|
||||
alt={getInitials(profile?.full_name)}
|
||||
width={128}
|
||||
height={128}
|
||||
<BasedAvatar
|
||||
src={profile?.avatar_url}
|
||||
fullName={profile?.full_name}
|
||||
className='h-32 w-32'
|
||||
fallbackClassName='text-4xl font-semibold'
|
||||
userIconSize={100}
|
||||
/>
|
||||
) : (
|
||||
<AvatarFallback className='text-4xl'>
|
||||
{profile?.full_name ? (
|
||||
getInitials(profile.full_name)
|
||||
) : (
|
||||
<User size={32} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<div
|
||||
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
|
||||
transition-all flex items-center justify-center'
|
||||
|
68
src/components/status/ConnectionStatus.tsx
Normal file
68
src/components/status/ConnectionStatus.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
|
||||
import { Badge, Button } from '@/components/ui';
|
||||
import type { ConnectionStatus as ConnectionStatusType } from '@/lib/hooks';
|
||||
|
||||
type ConnectionStatusProps = {
|
||||
status: ConnectionStatusType;
|
||||
onReconnect?: () => void;
|
||||
showAsButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getConnectionIcon = (status: ConnectionStatusType) => {
|
||||
switch (status) {
|
||||
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 = (status: ConnectionStatusType) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'disconnected':
|
||||
return 'Disconnected';
|
||||
case 'updating':
|
||||
return 'Updating...';
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectionStatus = ({
|
||||
status,
|
||||
onReconnect,
|
||||
showAsButton = false,
|
||||
className = '',
|
||||
}: ConnectionStatusProps) => {
|
||||
if (showAsButton && status === 'disconnected' && onReconnect) {
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onReconnect}
|
||||
className={`flex items-center gap-2 cursor-pointer ${className}`}
|
||||
>
|
||||
{getConnectionIcon(status)}
|
||||
<span className='text-base'>{getConnectionText(status)}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={`flex items-center gap-2 ${className}`}
|
||||
>
|
||||
{getConnectionIcon(status)}
|
||||
<span className='text-base'>{getConnectionText(status)}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
@ -106,7 +106,7 @@ export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
|
||||
className='w-8 h-8 md:w-12 md:h-12'
|
||||
/>
|
||||
<h1 className='text-lg md:text-2xl lg:text-4xl font-bold pl-2 md:pl-4'>
|
||||
{user && user.id !== '' ? 'User History' : 'All History'}
|
||||
{user && user.id !== '' ? `${user.full_name}'s History` : 'All History'}
|
||||
</h1>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
|
373
src/components/status/List.tsx
Normal file
373
src/components/status/List.tsx
Normal file
@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import type { UserWithStatus } from '@/lib/hooks';
|
||||
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { ConnectionStatus, HistoryDrawer } from '@/components/status';
|
||||
import type { Profile } from '@/utils/supabase';
|
||||
import { makeConditionalClassName } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Clock, Calendar } from 'lucide-react';
|
||||
import { useStatusData, useSharedStatusSubscription } from '@/lib/hooks';
|
||||
import { formatTime, formatDate } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
|
||||
type ListProps = {
|
||||
initialStatuses: UserWithStatus[]
|
||||
};
|
||||
|
||||
export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { tvMode } = useTVMode();
|
||||
|
||||
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 {
|
||||
data: usersWithStatuses = initialStatuses,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
newStatuses,
|
||||
updateStatusMutation,
|
||||
} = useStatusData({
|
||||
initialData: initialStatuses,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// In your StatusList component
|
||||
const { connectionStatus, connect: reconnect } = useSharedStatusSubscription(() => {
|
||||
refetch().catch((error) => {
|
||||
console.error('Error refetching statuses:', error);
|
||||
});
|
||||
});
|
||||
|
||||
//const { connectionStatus, connect: reconnect } = useStatusSubscription({
|
||||
//enabled: isAuthenticated,
|
||||
//onStatusUpdate: () => {
|
||||
//refetch().catch((error) => {
|
||||
//console.error('Error refetching statuses:', error);
|
||||
//});
|
||||
//},
|
||||
//});
|
||||
|
||||
const handleUpdateStatus = () => {
|
||||
if (!isAuthenticated) {
|
||||
setUpdateStatusMessage(
|
||||
'Error: You must be signed in to update technician statuses!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusInput.length < 3 || statusInput.length > 80) {
|
||||
setUpdateStatusMessage(
|
||||
'Error: Your status must be between 3 & 80 characters long!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusMutation.mutate({
|
||||
usersWithStatuses: selectedUsers,
|
||||
status: statusInput.trim(),
|
||||
});
|
||||
|
||||
setSelectedUsers([]);
|
||||
setStatusInput('');
|
||||
setUpdateStatusMessage('');
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
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: 'flex flex-col mx-auto space-y-4 items-center',
|
||||
on: 'lg:w-11/12 w-full mt-15',
|
||||
off: 'sm:w-5/6 md:3/4 lg:w-1/2',
|
||||
});
|
||||
|
||||
const headerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'w-full',
|
||||
on: 'hidden',
|
||||
off: 'flex mb-4 justify-between',
|
||||
});
|
||||
|
||||
const cardContainerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: '',
|
||||
on: '',
|
||||
off: 'space-y-3 items-center justify-center w-full',
|
||||
});
|
||||
|
||||
const cardClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName:
|
||||
'transition-all duration-300 hover:shadow-md hover:bg-muted/50 cursor-pointer',
|
||||
on: 'lg:text-4xl',
|
||||
off: 'lg:text-base lg:w-full',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={headerClassName}>
|
||||
<div className='flex items-center gap-10'>
|
||||
<div className='flex gap-2'>
|
||||
<Checkbox
|
||||
id='select-all'
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAllChange}
|
||||
className='size-6'
|
||||
/>
|
||||
<label htmlFor='select-all' className='font-medium'>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
{!tvMode && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
<p>Miss the old table?</p>
|
||||
<Link
|
||||
href='/status/table'
|
||||
className='italic font-semibold text-accent-foreground'
|
||||
>
|
||||
Find it here!
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConnectionStatus
|
||||
status={connectionStatus}
|
||||
onReconnect={reconnect}
|
||||
showAsButton={connectionStatus === 'disconnected'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardContainerClassName}>
|
||||
{usersWithStatuses.map((userWithStatus) => {
|
||||
const isSelected = selectedUsers.some(
|
||||
(u) => u.user.id === userWithStatus.user.id
|
||||
);
|
||||
const isNewStatus = newStatuses.has(userWithStatus);
|
||||
const isUpdatedByOther =
|
||||
userWithStatus.updated_by &&
|
||||
userWithStatus.updated_by.id !== userWithStatus.user.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${cardClassName}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
|
||||
`}
|
||||
>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{!tvMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() =>
|
||||
handleCheckboxChange(userWithStatus)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<BasedAvatar
|
||||
src={userWithStatus.user.avatar_url}
|
||||
fullName={userWithStatus.user.full_name}
|
||||
className={tvMode ? 'w-24 h-24' : 'w-16 h-16'}
|
||||
/>
|
||||
<div className='my-auto'>
|
||||
<h3
|
||||
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-2xl'}`}
|
||||
>
|
||||
{userWithStatus.user.full_name}
|
||||
</h3>
|
||||
{isUpdatedByOther && (
|
||||
<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-5 h-5'
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
|
||||
>
|
||||
Updated by {userWithStatus.updated_by?.full_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='my-auto'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Clock className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`} />
|
||||
<span className={`${tvMode ? 'text-3xl' : 'text-xl'}`}>
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Calendar
|
||||
className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`}
|
||||
/>
|
||||
<span className={`${tvMode ? 'text-3xl' : 'text-xl'}`}>
|
||||
{formatDate(userWithStatus.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div
|
||||
className={`
|
||||
p-4 rounded-lg bg-muted/30 hover:bg-muted/50
|
||||
transition-colors cursor-pointer text-left
|
||||
${tvMode ? 'text-4xl' : 'text-xl'}
|
||||
`}
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
<p className='font-medium'>{userWithStatus.status}</p>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<Card className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates have been made in the past day.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!tvMode && (
|
||||
<Card className='p-6 mt-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h3 className='text-lg font-semibold'>Update Status</h3>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex gap-4'>
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='Enter status'
|
||||
className='flex-1 text-base'
|
||||
value={statusInput}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!updateStatusMutation.isPending
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SubmitButton
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className='px-6'
|
||||
>
|
||||
{selectedUsers.length > 0
|
||||
? `Update status for ${selectedUsers.length}
|
||||
${selectedUsers.length > 1 ? 'users' : 'user'}`
|
||||
: 'Update status'
|
||||
}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
{updateStatusMessage &&
|
||||
(updateStatusMessage.includes('Error') ||
|
||||
updateStatusMessage.includes('error') ||
|
||||
updateStatusMessage.includes('failed') ||
|
||||
updateStatusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: updateStatusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: updateStatusMessage }} />
|
||||
))}
|
||||
</div>
|
||||
<div className='flex justify-center mt-2'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={tvMode ? 'text-3xl p-6' : ''}
|
||||
>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
377
src/components/status/Table.tsx
Normal file
377
src/components/status/Table.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import type { UserWithStatus } from '@/lib/hooks';
|
||||
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { ConnectionStatus, HistoryDrawer } from '@/components/status';
|
||||
import type { Profile } from '@/utils/supabase';
|
||||
import { makeConditionalClassName } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Clock, Calendar } from 'lucide-react';
|
||||
import { useSharedStatusSubscription, useStatusData } from '@/lib/hooks';
|
||||
import { formatTime, formatDate } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
type TableProps = {
|
||||
initialStatuses: UserWithStatus[];
|
||||
};
|
||||
|
||||
export const TechTable = ({ initialStatuses = [] }: TableProps) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { tvMode } = useTVMode();
|
||||
|
||||
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 {
|
||||
data: usersWithStatuses = initialStatuses,
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch,
|
||||
newStatuses,
|
||||
updateStatusMutation,
|
||||
} = useStatusData({
|
||||
initialData: initialStatuses,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
// In your StatusList component
|
||||
const { connectionStatus, connect: reconnect } = useSharedStatusSubscription(() => {
|
||||
refetch().catch((error) => {
|
||||
console.error('Error refetching statuses:', error);
|
||||
});
|
||||
});
|
||||
//const { connectionStatus, connect: reconnect } = useStatusSubscription({
|
||||
//enabled: isAuthenticated,
|
||||
//onStatusUpdate: () => {
|
||||
//refetch().catch((error) => {
|
||||
//console.error('Error refetching statuses:', error);
|
||||
//});
|
||||
//},
|
||||
//});
|
||||
|
||||
const handleUpdateStatus = () => {
|
||||
if (!isAuthenticated) {
|
||||
setUpdateStatusMessage(
|
||||
'Error: You must be signed in to update technician statuses!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusInput.length < 3 || statusInput.length > 80) {
|
||||
setUpdateStatusMessage(
|
||||
'Error: Your status must be between 3 & 80 characters long!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusMutation.mutate({
|
||||
usersWithStatuses: selectedUsers,
|
||||
status: statusInput.trim(),
|
||||
});
|
||||
|
||||
setSelectedUsers([]);
|
||||
setStatusInput('');
|
||||
setUpdateStatusMessage('');
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
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',
|
||||
on: 'lg:w-11/12 w-full',
|
||||
off: 'w-5/6',
|
||||
});
|
||||
const headerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'w-full mb-2 flex justify-between',
|
||||
on: 'mt-25',
|
||||
off: 'mb-2',
|
||||
});
|
||||
const thClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'py-4 px-4 border font-semibold ',
|
||||
on: 'lg:text-6xl xl:min-w-[420px]',
|
||||
off: 'lg:text-5xl xl:min-w-[300px]',
|
||||
});
|
||||
const tdClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'py-2 px-2 border',
|
||||
on: 'lg:text-5xl',
|
||||
off: 'lg:text-4xl',
|
||||
});
|
||||
const tCheckboxClassName = `py-3 px-4 border`;
|
||||
const checkBoxClassName = `lg:scale-200 cursor-pointer`;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={headerClassName}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConnectionStatus
|
||||
status={connectionStatus}
|
||||
onReconnect={reconnect}
|
||||
showAsButton={connectionStatus === 'disconnected'}
|
||||
/>
|
||||
{!tvMode && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
<p>Tired of the old table? {' '}</p>
|
||||
<Link
|
||||
href='/status/list'
|
||||
className='italic font-semibold text-accent-foreground'
|
||||
>
|
||||
Try out the new status list!
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className='w-full text-center rounded-md'>
|
||||
<thead>
|
||||
<tr className='bg-muted'>
|
||||
{!tvMode && (
|
||||
<th className={tCheckboxClassName}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxClassName}
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAllChange}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className={thClassName}>Technician</th>
|
||||
<th className={thClassName}>
|
||||
<Drawer>
|
||||
<DrawerTrigger className='hover:text-foreground/60 cursor-pointer'>
|
||||
Status
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</th>
|
||||
<th className={thClassName}>Updated At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usersWithStatuses.map((userWithStatus, index) => {
|
||||
const isSelected = selectedUsers.some(
|
||||
(u) => u.user.id === userWithStatus.user.id,
|
||||
);
|
||||
const isNewStatus = newStatuses.has(userWithStatus);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
|
||||
hover:bg-muted/75 transition-all duration-300
|
||||
`}
|
||||
>
|
||||
{!tvMode && (
|
||||
<td className={tCheckboxClassName}>
|
||||
<input
|
||||
type='checkbox'
|
||||
className={checkBoxClassName}
|
||||
checked={isSelected}
|
||||
onChange={() => handleCheckboxChange(userWithStatus)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className={tdClassName}>
|
||||
<div className='flex items-center gap-3'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.user.avatar_url}
|
||||
fullName={userWithStatus.user.full_name}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-4xl'}`}
|
||||
>
|
||||
{userWithStatus.user.full_name ?? 'Unknown User'}
|
||||
</p>
|
||||
{userWithStatus.updated_by &&
|
||||
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-5 h-5'
|
||||
/>
|
||||
<span
|
||||
className={tvMode ? 'text-lg' : 'text-base'}
|
||||
>
|
||||
Updated by {userWithStatus.updated_by.full_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tdClassName}>
|
||||
<Drawer>
|
||||
<DrawerTrigger
|
||||
className='text-center w-full p-2 rounded-md hover:bg-muted transition-colors'
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
{userWithStatus.status}
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
</td>
|
||||
<td className={tdClassName}>
|
||||
<div className='flex w-full'>
|
||||
<div className='flex items-start xl:w-1/6'></div>
|
||||
<div className='flex flex-col my-auto items-start'>
|
||||
<div className='flex gap-4 my-1'>
|
||||
<Clock className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`} />
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</div>
|
||||
<div className='flex gap-4 my-1'>
|
||||
<Calendar
|
||||
className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`}
|
||||
/>
|
||||
{formatDate(userWithStatus.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<div className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatusMessage &&
|
||||
(updateStatusMessage.includes('Error') ||
|
||||
updateStatusMessage.includes('error') ||
|
||||
updateStatusMessage.includes('failed') ||
|
||||
updateStatusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: updateStatusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: updateStatusMessage }} />
|
||||
))}
|
||||
|
||||
{!tvMode && (
|
||||
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='New Status'
|
||||
className={
|
||||
'min-w-[120px] lg:max-w-[400px] py-6 px-3 rounded-xl \
|
||||
border bg-background lg:text-2xl focus:outline-none \
|
||||
focus:ring-2 focus:ring-primary'
|
||||
}
|
||||
value={statusInput}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !updateStatusMutation.isPending) {
|
||||
e.preventDefault();
|
||||
handleUpdateStatus();
|
||||
}
|
||||
}}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
/>
|
||||
<SubmitButton
|
||||
size='xl'
|
||||
className={
|
||||
'px-8 rounded-xl font-semibold lg:text-2xl \
|
||||
disabled:opacity-50 disabled:cursor-not-allowed \
|
||||
cursor-pointer'
|
||||
}
|
||||
onClick={handleUpdateStatus}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
pendingText='Updating...'
|
||||
>
|
||||
{selectedUsers.length > 0
|
||||
? `Update status for ${selectedUsers.length}
|
||||
${selectedUsers.length > 1 ? 'users' : 'user'}`
|
||||
: 'Update status'
|
||||
}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Status History Drawer */}
|
||||
{!tvMode && (
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant='outline' className={tvMode ? 'text-3xl p-6' : ''}>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,312 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createClient } from '@/utils/supabase';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import {
|
||||
getRecentUsersWithStatuses,
|
||||
updateStatuses,
|
||||
updateUserStatus,
|
||||
type UserWithStatus,
|
||||
} from '@/lib/hooks';
|
||||
import { 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 type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
type TechTableProps = {
|
||||
initialStatuses: UserWithStatus[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const TechTable = ({
|
||||
initialStatuses = [],
|
||||
className = 'w-full max-w-7xl mx-auto px-4',
|
||||
}: TechTableProps) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { tvMode } = useTVMode();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [statusInput, setStatusInput] = useState('');
|
||||
const [usersWithStatuses, setUsersWithStatuses] =
|
||||
useState<UserWithStatus[]>(initialStatuses);
|
||||
const [selectedHistoryUser, setSelectedHistoryUser] =
|
||||
useState<Profile | null>(null);
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
|
||||
const fetchRecentUsersWithStatuses = useCallback(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}`);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const data = await fetchRecentUsersWithStatuses();
|
||||
setUsersWithStatuses(data);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData().catch((error) => {
|
||||
console.error('Error loading data:', error);
|
||||
});
|
||||
}, [fetchRecentUsersWithStatuses, isAuthenticated]);
|
||||
|
||||
const updateStatus = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error('You must be signed in to update statuses.');
|
||||
return;
|
||||
}
|
||||
if (!statusInput.trim()) {
|
||||
toast.error('Please enter a valid status.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (selectedIds.length === 0) {
|
||||
const result = await updateUserStatus(statusInput);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
toast.success(`Status updated for signed in user.`);
|
||||
} else {
|
||||
const result = await updateStatuses(selectedIds, statusInput);
|
||||
if (!result.success) throw new Error(result.error);
|
||||
toast.success(
|
||||
`Status updated for ${selectedIds.length} selected users.`,
|
||||
);
|
||||
}
|
||||
setSelectedIds([]);
|
||||
setStatusInput('');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to update status: ${errorMessage}`);
|
||||
}
|
||||
}, [isAuthenticated, statusInput, selectedIds]);
|
||||
|
||||
const handleCheckboxChange = (id: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id)
|
||||
? prev.filter((prevId) => prevId !== id)
|
||||
: [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllChange = () => {
|
||||
if (selectAll) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(usersWithStatuses.map((tech) => tech.user.id));
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectAll(
|
||||
selectedIds.length === usersWithStatuses.length &&
|
||||
usersWithStatuses.length > 0,
|
||||
);
|
||||
}, [selectedIds.length, usersWithStatuses.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
//if (channelRef.current) {
|
||||
//const supabase = createClient();
|
||||
//supabase.removeChannel(channelRef.current).catch((error) => {
|
||||
//console.error(`Error unsubscribing from status updates: ${error}`);
|
||||
//});
|
||||
//channelRef.current = null;
|
||||
//}
|
||||
const supabase = createClient();
|
||||
|
||||
const channel = supabase
|
||||
.channel('status_updates', {
|
||||
config: { broadcast: { self: true }}
|
||||
})
|
||||
.on('broadcast', { event: 'status_updated' }, (payload) => {
|
||||
const { user_status } = payload.payload as {
|
||||
user_status: UserWithStatus;
|
||||
timestamp: string;
|
||||
};
|
||||
console.log('Received status update:', user_status);
|
||||
|
||||
setUsersWithStatuses((prevUsers) => {
|
||||
const existingUserIndex = prevUsers.findIndex((u) =>
|
||||
u.user.id === user_status.user.id,
|
||||
);
|
||||
|
||||
if (existingUserIndex !== -1) {
|
||||
const updatedUsers = [...prevUsers];
|
||||
updatedUsers[existingUserIndex] = {
|
||||
user: user_status.user, // Use the user from the broadcast
|
||||
status: user_status.status,
|
||||
created_at: user_status.created_at,
|
||||
updated_by: user_status.updated_by,
|
||||
};
|
||||
return updatedUsers;
|
||||
} else {
|
||||
// Add new user to list!
|
||||
return [user_status, ...prevUsers];
|
||||
}
|
||||
});
|
||||
})
|
||||
.subscribe((status) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
if (status === 'SUBSCRIBED') {
|
||||
console.log('Successfully subscribed to status updates!');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CHANNEL_ERROR') {
|
||||
console.error('Error subscribing to status updates.')
|
||||
}
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
|
||||
return () => {
|
||||
if (channelRef.current) {
|
||||
supabase.removeChannel(channelRef.current).catch((error) => {
|
||||
console.error(`Error unsubscribing from status updates: ${error}`);
|
||||
});
|
||||
channelRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<table
|
||||
className={`w-full text-center border-collapse \
|
||||
${tvMode ? 'text-4xl lg:text-5xl' : 'text-base lg:text-lg'}`}
|
||||
>
|
||||
<thead>
|
||||
<tr className='bg-muted'>
|
||||
{!tvMode && (
|
||||
<th className='py-3 px-3 border'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='scale-125 cursor-pointer'
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAllChange}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className='py-3 px-4 border font-semibold'>Name</th>
|
||||
<th className='py-3 px-4 border font-semibold'>
|
||||
<Drawer>
|
||||
<DrawerTrigger className='hover:underline'>
|
||||
Status
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</th>
|
||||
<th className='py-3 px-4 border font-semibold'>Updated At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usersWithStatuses.map((userWithStatus, index) => (
|
||||
<tr
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
|
||||
hover:bg-muted/75 transition-all duration-300
|
||||
`}
|
||||
>
|
||||
{!tvMode && (
|
||||
<td className='py-2 px-3 border'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='scale-125 cursor-pointer'
|
||||
checked={selectedIds.includes(userWithStatus.user.id)}
|
||||
onChange={() =>
|
||||
handleCheckboxChange(userWithStatus.user.id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className='py-3 px-4 border font-medium'>
|
||||
{userWithStatus.user.full_name ?? 'Unknown User'}
|
||||
</td>
|
||||
<td className='py-3 px-4 border'>
|
||||
<Drawer>
|
||||
<DrawerTrigger
|
||||
className='text-left w-full p-2 rounded hover:bg-muted transition-colors'
|
||||
onClick={() => setSelectedHistoryUser(userWithStatus.user)}
|
||||
>
|
||||
{userWithStatus.status}
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
</td>
|
||||
<td className='py-3 px-4 border text-muted-foreground'>
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!tvMode && (
|
||||
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
|
||||
<input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='New Status'
|
||||
className={
|
||||
'min-w-[120px] lg:min-w-[400px] py-2 px-3 rounded-xl \
|
||||
border bg-background lg:text-2xl focus:outline-none \
|
||||
focus:ring-2 focus:ring-primary'
|
||||
}
|
||||
value={statusInput}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
updateStatus().catch((error) => {
|
||||
toast.error(`Failed to update status: ${error as Error}`);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SubmitButton
|
||||
size='xl'
|
||||
className={
|
||||
'px-8 rounded-xl font-semibold lg:text-2xl \
|
||||
disabled:opacity-50 disabled:cursor-not-allowed \
|
||||
cursor-pointer'
|
||||
}
|
||||
onClick={() => updateStatus()}
|
||||
disabled={!statusInput.trim()}
|
||||
disabledNotLoading={true}
|
||||
>
|
||||
Update
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,2 +1,4 @@
|
||||
export * from './ConnectionStatus';
|
||||
export * from './HistoryDrawer';
|
||||
export * from './TechTable';
|
||||
export * from './List';
|
||||
export * from './Table';
|
||||
|
@ -2,9 +2,60 @@
|
||||
|
||||
import * as React from 'react';
|
||||
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;
|
||||
imageClassName?: string;
|
||||
fallbackClassName?: string;
|
||||
userIconSize?: number;
|
||||
};
|
||||
|
||||
function BasedAvatar({
|
||||
src = null,
|
||||
fullName = null,
|
||||
imageClassName = '',
|
||||
fallbackClassName = '',
|
||||
userIconSize = 32,
|
||||
className,
|
||||
...props
|
||||
}: BasedAvatarProps) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot='avatar'
|
||||
className={cn(
|
||||
'cursor-pointer relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{src ? (
|
||||
<AvatarImage src={src} className={imageClassName} />
|
||||
) : (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot='avatar-fallback'
|
||||
className={cn(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
{fullName ? (
|
||||
fullName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
) : (
|
||||
<User size={userIconSize} />
|
||||
)}
|
||||
</AvatarPrimitive.Fallback>
|
||||
)}
|
||||
</AvatarPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
@ -37,7 +88,9 @@ function AvatarImage({
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
}: React.ComponentProps<
|
||||
typeof AvatarPrimitive.Fallback & { fullName: string }
|
||||
>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot='avatar-fallback'
|
||||
@ -50,4 +103,4 @@ function AvatarFallback({
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
export { Avatar, BasedAvatar, AvatarImage, AvatarFallback };
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -3,8 +3,7 @@
|
||||
import 'server-only';
|
||||
import { createServerClient } from '@/utils/supabase';
|
||||
import { headers } from 'next/headers';
|
||||
import type { User } from '@/utils/supabase';
|
||||
import type { Result } from '.';
|
||||
import type { User, Result } from '@/utils/supabase';
|
||||
|
||||
export const signUp = async (
|
||||
formData: FormData,
|
||||
@ -58,31 +57,37 @@ export const signIn = async (formData: FormData): Promise<Result<null>> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
|
||||
type OAuthReturn = {
|
||||
provider: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const signInWithMicrosoft = async (): Promise<Result<OAuthReturn>> => {
|
||||
const supabase = await createServerClient();
|
||||
const origin = (await headers()).get('origin');
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'azure',
|
||||
options: {
|
||||
scopes: 'openid, profile email offline_access',
|
||||
scopes: 'openid profile email offline_access',
|
||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||
},
|
||||
});
|
||||
if (error) return { success: false, error: error.message };
|
||||
return { success: true, data: data.url };
|
||||
return { success: true, data };
|
||||
};
|
||||
|
||||
export const signInWithApple = async (): Promise<Result<string>> => {
|
||||
export const signInWithApple = async (): Promise<Result<OAuthReturn>> => {
|
||||
const supabase = await createServerClient();
|
||||
const origin = process.env.BASE_URL!;
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: 'openid profile email offline_access',
|
||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||
},
|
||||
});
|
||||
if (error) return { success: false, error: error.message };
|
||||
return { success: true, data: data.url };
|
||||
return { success: true, data };
|
||||
};
|
||||
|
||||
export const forgotPassword = async (
|
||||
|
@ -1,22 +1,66 @@
|
||||
'use server';
|
||||
'use client';
|
||||
|
||||
import 'server-only';
|
||||
import { createServerClient, type Profile } from '@/utils/supabase';
|
||||
import { getUser } from '@/lib/actions';
|
||||
import { getSignedUrl, getUser } from '@/lib/actions';
|
||||
import type { Result } from '.';
|
||||
|
||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
||||
export const getProfile = async (
|
||||
userId: string | null = null,
|
||||
): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (userId == null) {
|
||||
const user = await getUser();
|
||||
if (!user.success || user.data === undefined)
|
||||
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();
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.data.id)
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
return { success: true, data: data as Profile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error getting profile',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getProfileWithAvatar = async (
|
||||
userId: string | null = null,
|
||||
): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (userId === null) {
|
||||
const user = await getUser();
|
||||
if (!user.success || user.data === undefined)
|
||||
throw new Error('User not found');
|
||||
userId = user.data.id;
|
||||
}
|
||||
const supabase = await createServerClient();
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
if (data.avatar_url) {
|
||||
const avatarUrl = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url: data.avatar_url,
|
||||
transform: { width: 128, height: 128 },
|
||||
});
|
||||
if (avatarUrl.success) {
|
||||
data.avatar_url = avatarUrl.data;
|
||||
}
|
||||
}
|
||||
return { success: true, data: data as Profile };
|
||||
} catch (error) {
|
||||
return {
|
||||
@ -33,18 +77,21 @@ type updateProfileProps = {
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
export const updateProfile = async ({
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
provider,
|
||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (
|
||||
full_name === undefined &&
|
||||
email === undefined &&
|
||||
avatar_url === undefined
|
||||
avatar_url === undefined &&
|
||||
provider === undefined
|
||||
)
|
||||
throw new Error('No profile data provided');
|
||||
|
||||
@ -59,11 +106,21 @@ export const updateProfile = async ({
|
||||
...(full_name !== undefined && { full_name }),
|
||||
...(email !== undefined && { email }),
|
||||
...(avatar_url !== undefined && { avatar_url }),
|
||||
...(provider !== undefined && { provider }),
|
||||
})
|
||||
.eq('id', userResponse.data.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
if (data.avatar_url) {
|
||||
const avatarUrl = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url: data.avatar_url,
|
||||
transform: { width: 128, height: 128 },
|
||||
});
|
||||
if (avatarUrl.success) data.avatar_url = avatarUrl.data;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: data as Profile,
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use server';
|
||||
import { createServerClient } from '@/utils/supabase';
|
||||
import type { Profile, Result } from '@/utils/supabase';
|
||||
import { getUser, getProfile } from '@/lib/hooks';
|
||||
import { getUser, getProfileWithAvatar, getSignedUrl } from '@/lib/actions';
|
||||
|
||||
export type UserWithStatus = {
|
||||
id?: string;
|
||||
@ -24,6 +24,16 @@ type PaginatedHistory = {
|
||||
export const getRecentUsersWithStatuses = async (): Promise<
|
||||
Result<UserWithStatus[]>
|
||||
> => {
|
||||
const getAvatarUrl = async (url: string | null | undefined) => {
|
||||
if (!url) return null;
|
||||
const avatarUrl = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url,
|
||||
transform: { width: 128, height: 128 },
|
||||
});
|
||||
if (avatarUrl.success) return avatarUrl.data;
|
||||
else return null;
|
||||
};
|
||||
try {
|
||||
const supabase = await createServerClient();
|
||||
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
||||
@ -55,7 +65,20 @@ export const getRecentUsersWithStatuses = async (): Promise<
|
||||
return true;
|
||||
});
|
||||
|
||||
return { success: true, data: filtered };
|
||||
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,
|
||||
);
|
||||
if (userWithStatus.updated_by?.avatar_url)
|
||||
userWithStatus.updated_by.avatar_url = await getAvatarUrl(
|
||||
userWithStatus.updated_by?.avatar_url,
|
||||
);
|
||||
filteredWithAvatars.push(userWithStatus);
|
||||
}
|
||||
|
||||
return { success: true, data: filteredWithAvatars };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Error: ${error as Error}` };
|
||||
}
|
||||
@ -91,48 +114,40 @@ export const broadcastStatusUpdates = async (
|
||||
};
|
||||
|
||||
export const updateStatuses = async (
|
||||
userIds: string[],
|
||||
usersWithStatuses: UserWithStatus[],
|
||||
status: string,
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
const supabase = await createServerClient();
|
||||
const userResponse = await getUser();
|
||||
if (!userResponse.success) throw new Error('Not authenticated!');
|
||||
const profileResponse = await getProfile();
|
||||
if (!profileResponse.success) throw new Error(profileResponse.error);
|
||||
const user = userResponse.data;
|
||||
const userProfile = profileResponse.data;
|
||||
|
||||
const inserts = userIds.map((usersId) => ({
|
||||
user_id: usersId,
|
||||
status,
|
||||
updated_by_id: user.id,
|
||||
}));
|
||||
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(inserts).select();
|
||||
if (insertedStatusesError) throw insertedStatusesError as Error;
|
||||
await supabase
|
||||
.from('statuses')
|
||||
.insert(
|
||||
usersWithStatuses.map((userWithStatus) => ({
|
||||
user_id: userWithStatus.user.id,
|
||||
status,
|
||||
updated_by_id: user.id,
|
||||
})),
|
||||
)
|
||||
.select();
|
||||
|
||||
if (insertedStatuses) {
|
||||
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
|
||||
for (const insertedStatus of insertedStatuses) {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', insertedStatus.user_id)
|
||||
.single();
|
||||
if (profileError) throw profileError as Error;
|
||||
|
||||
if (profile) {
|
||||
broadcastArray.push({
|
||||
user: profile,
|
||||
status: insertedStatus.status,
|
||||
created_at: insertedStatus.created_at,
|
||||
updated_by: userProfile,
|
||||
});
|
||||
}
|
||||
}
|
||||
await broadcastStatusUpdates(broadcastArray);
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { success: true, data: undefined };
|
||||
} catch (error) {
|
||||
@ -148,33 +163,31 @@ export const updateUserStatus = async (
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
const supabase = await createServerClient();
|
||||
const userResponse = await getUser();
|
||||
if (!userResponse.success)
|
||||
throw new Error(`Not authenticated! ${userResponse.error}`);
|
||||
const profileResponse = await getProfile();
|
||||
const profileResponse = await getProfileWithAvatar();
|
||||
if (!profileResponse.success)
|
||||
throw new Error(`Could not get profile! ${profileResponse.error}`);
|
||||
const user = userResponse.data;
|
||||
throw new Error(`Not authenticated! ${profileResponse.error}`);
|
||||
const userProfile = profileResponse.data;
|
||||
|
||||
const { data: insertedStatus, error: insertedStatusError } = await supabase
|
||||
.from('statuses')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
user_id: userProfile.id,
|
||||
status,
|
||||
updated_by_id: user.id,
|
||||
updated_by_id: userProfile.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (insertedStatusError) throw insertedStatusError as Error;
|
||||
|
||||
const userStatus: UserWithStatus = {
|
||||
await broadcastStatusUpdates([
|
||||
{
|
||||
user: userProfile,
|
||||
status: insertedStatus.status,
|
||||
created_at: insertedStatus.created_at,
|
||||
};
|
||||
updated_by: userProfile,
|
||||
},
|
||||
]);
|
||||
|
||||
await broadcastStatusUpdates([userStatus]);
|
||||
return { success: true, data: undefined };
|
||||
} catch (error) {
|
||||
return {
|
||||
@ -220,14 +233,6 @@ export const getUserHistory = async (
|
||||
};
|
||||
if (statusesError) throw statusesError as Error;
|
||||
|
||||
const { data: profile, error: profileError } = (await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single()) as { data: Profile; error: unknown };
|
||||
if (profileError) throw profileError as Error;
|
||||
if (!profile) throw new Error('User profile not found!');
|
||||
|
||||
const totalCount = count ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / perPage);
|
||||
|
||||
|
@ -54,7 +54,12 @@ export const signIn = async (formData: FormData): Promise<Result<null>> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
|
||||
type OAuthReturn = {
|
||||
provider: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const signInWithMicrosoft = async (): Promise<Result<OAuthReturn>> => {
|
||||
const supabase = createClient();
|
||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
@ -65,20 +70,21 @@ export const signInWithMicrosoft = async (): Promise<Result<string>> => {
|
||||
},
|
||||
});
|
||||
if (error) return { success: false, error: error.message };
|
||||
return { success: true, data: data.url };
|
||||
return { success: true, data };
|
||||
};
|
||||
|
||||
export const signInWithApple = async (): Promise<Result<string>> => {
|
||||
export const signInWithApple = async (): Promise<Result<OAuthReturn>> => {
|
||||
const supabase = createClient();
|
||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: 'openid profile email offline_access',
|
||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||
},
|
||||
});
|
||||
if (error) return { success: false, error: error.message };
|
||||
return { success: true, data: data.url };
|
||||
return { success: true, data };
|
||||
};
|
||||
|
||||
export const forgotPassword = async (
|
||||
|
@ -3,6 +3,8 @@ export * from './public';
|
||||
export * from './status';
|
||||
export * from './storage';
|
||||
export * from './useFileUpload';
|
||||
export * from './useSharedStatusSubscription';
|
||||
export * from './useStatusData';
|
||||
|
||||
export type Result<T> =
|
||||
| { success: true; data: T }
|
||||
|
@ -1,21 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { createClient, type Profile } from '@/utils/supabase';
|
||||
import { getUser } from '@/lib/hooks';
|
||||
import { getSignedUrl, getUser } from '@/lib/hooks';
|
||||
import type { Result } from '.';
|
||||
|
||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
||||
export const getProfile = async (
|
||||
userId: string | null = null,
|
||||
): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (userId == null) {
|
||||
const user = await getUser();
|
||||
if (!user.success || user.data === undefined)
|
||||
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();
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.data.id)
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
return { success: true, data: data as Profile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error getting profile',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getProfileWithAvatar = async (
|
||||
userId: string | null = null,
|
||||
): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (userId === null) {
|
||||
const user = await getUser();
|
||||
if (!user.success || user.data === undefined)
|
||||
throw new Error('User not found');
|
||||
userId = user.data.id;
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
if (data.avatar_url) {
|
||||
const avatarUrl = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url: data.avatar_url,
|
||||
transform: { width: 128, height: 128 },
|
||||
});
|
||||
if (avatarUrl.success) {
|
||||
data.avatar_url = avatarUrl.data;
|
||||
}
|
||||
}
|
||||
return { success: true, data: data as Profile };
|
||||
} catch (error) {
|
||||
return {
|
||||
@ -32,18 +77,21 @@ type updateProfileProps = {
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
export const updateProfile = async ({
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
provider,
|
||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||
try {
|
||||
if (
|
||||
full_name === undefined &&
|
||||
email === undefined &&
|
||||
avatar_url === undefined
|
||||
avatar_url === undefined &&
|
||||
provider === undefined
|
||||
)
|
||||
throw new Error('No profile data provided');
|
||||
|
||||
@ -58,11 +106,21 @@ export const updateProfile = async ({
|
||||
...(full_name !== undefined && { full_name }),
|
||||
...(email !== undefined && { email }),
|
||||
...(avatar_url !== undefined && { avatar_url }),
|
||||
...(provider !== undefined && { provider }),
|
||||
})
|
||||
.eq('id', userResponse.data.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
if (data.avatar_url) {
|
||||
const avatarUrl = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url: data.avatar_url,
|
||||
transform: { width: 128, height: 128 },
|
||||
});
|
||||
if (avatarUrl.success) data.avatar_url = avatarUrl.data;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: data as Profile,
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import { createClient } from '@/utils/supabase';
|
||||
import type { Profile, Result } from '@/utils/supabase';
|
||||
import { getUser, getProfile } from '@/lib/hooks';
|
||||
import { getUser, getProfileWithAvatar, getSignedUrl } from '@/lib/hooks';
|
||||
|
||||
export type UserWithStatus = {
|
||||
id?: string;
|
||||
@ -24,18 +24,30 @@ type PaginatedHistory = {
|
||||
export const getRecentUsersWithStatuses = async (): Promise<
|
||||
Result<UserWithStatus[]>
|
||||
> => {
|
||||
const getAvatarUrl = async (url: string | null | undefined) => {
|
||||
if (!url) return null;
|
||||
const avatarUrl = await getSignedUrl({
|
||||
bucket: 'avatars',
|
||||
url,
|
||||
transform: { width: 128, height: 128 },
|
||||
});
|
||||
if (avatarUrl.success) return avatarUrl.data;
|
||||
else return null;
|
||||
};
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
||||
|
||||
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[];
|
||||
@ -53,7 +65,20 @@ export const getRecentUsersWithStatuses = async (): Promise<
|
||||
return true;
|
||||
});
|
||||
|
||||
return { success: true, data: filtered };
|
||||
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,
|
||||
);
|
||||
if (userWithStatus.updated_by?.avatar_url)
|
||||
userWithStatus.updated_by.avatar_url = await getAvatarUrl(
|
||||
userWithStatus.updated_by?.avatar_url,
|
||||
);
|
||||
filteredWithAvatars.push(userWithStatus);
|
||||
}
|
||||
|
||||
return { success: true, data: filteredWithAvatars };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Error: ${error as Error}` };
|
||||
}
|
||||
@ -89,92 +114,81 @@ export const broadcastStatusUpdates = async (
|
||||
};
|
||||
|
||||
export const updateStatuses = async (
|
||||
userIds: string[],
|
||||
usersWithStatuses: UserWithStatus[],
|
||||
status: string,
|
||||
): Promise<Result<void>> => {
|
||||
): Promise<Result<UserWithStatus[]>> => {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const userResponse = await getUser();
|
||||
if (!userResponse.success) throw new Error('Not authenticated!');
|
||||
const profileResponse = await getProfile();
|
||||
if (!profileResponse.success) throw new Error(profileResponse.error);
|
||||
const user = userResponse.data;
|
||||
const userProfile = profileResponse.data;
|
||||
|
||||
const inserts = userIds.map((usersId) => ({
|
||||
user_id: usersId,
|
||||
status,
|
||||
updated_by_id: user.id,
|
||||
}));
|
||||
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(inserts).select();
|
||||
if (insertedStatusesError) throw insertedStatusesError as Error;
|
||||
await supabase
|
||||
.from('statuses')
|
||||
.insert(
|
||||
usersWithStatuses.map((userWithStatus) => ({
|
||||
user_id: userWithStatus.user.id,
|
||||
status,
|
||||
updated_by_id: user.id,
|
||||
})),
|
||||
)
|
||||
.select();
|
||||
|
||||
if (insertedStatuses) {
|
||||
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
|
||||
for (const insertedStatus of insertedStatuses) {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', insertedStatus.user_id)
|
||||
.single();
|
||||
if (profileError) throw profileError as Error;
|
||||
|
||||
if (profile) {
|
||||
broadcastArray.push({
|
||||
user: profile,
|
||||
status: insertedStatus.status,
|
||||
created_at: insertedStatus.created_at,
|
||||
updated_by: userProfile,
|
||||
if (insertedStatusesError) throw new Error('Error inserting statuses!');
|
||||
else if (insertedStatuses) {
|
||||
const createdAtFallback = new Date(Date.now()).toISOString();
|
||||
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!' };
|
||||
}
|
||||
}
|
||||
await broadcastStatusUpdates(broadcastArray);
|
||||
}
|
||||
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<Result<void>> => {
|
||||
): Promise<Result<UserWithStatus[]>> => {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const userResponse = await getUser();
|
||||
if (!userResponse.success)
|
||||
throw new Error(`Not authenticated! ${userResponse.error}`);
|
||||
const profileResponse = await getProfile();
|
||||
const profileResponse = await getProfileWithAvatar();
|
||||
if (!profileResponse.success)
|
||||
throw new Error(`Could not get profile! ${profileResponse.error}`);
|
||||
const user = userResponse.data;
|
||||
throw new Error(`Not authenticated! ${profileResponse.error}`);
|
||||
const userProfile = profileResponse.data;
|
||||
|
||||
const { data: insertedStatus, error: insertedStatusError } = await supabase
|
||||
.from('statuses')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
user_id: userProfile.id,
|
||||
status,
|
||||
updated_by_id: user.id,
|
||||
updated_by_id: userProfile.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (insertedStatusError) throw insertedStatusError as Error;
|
||||
|
||||
const userStatus: UserWithStatus = {
|
||||
const statusUpdate = {
|
||||
user: userProfile,
|
||||
status: insertedStatus.status,
|
||||
created_at: insertedStatus.created_at,
|
||||
updated_by: userProfile,
|
||||
};
|
||||
await broadcastStatusUpdates([statusUpdate]);
|
||||
|
||||
await broadcastStatusUpdates([userStatus]);
|
||||
return { success: true, data: undefined };
|
||||
return { success: true, data: [statusUpdate] };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
@ -219,14 +233,6 @@ export const getUserHistory = async (
|
||||
};
|
||||
if (statusesError) throw statusesError as Error;
|
||||
|
||||
const { data: profile, error: profileError } = (await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single()) as { data: Profile; error: unknown };
|
||||
if (profileError) throw profileError as Error;
|
||||
if (!profile) throw new Error('User profile not found!');
|
||||
|
||||
const totalCount = count ?? 0;
|
||||
const totalPages = Math.ceil(totalCount / perPage);
|
||||
|
||||
|
131
src/lib/hooks/useSharedStatusSubscription.ts
Normal file
131
src/lib/hooks/useSharedStatusSubscription.ts
Normal file
@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createClient } from '@/utils/supabase';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
export type ConnectionStatus =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'updating';
|
||||
|
||||
// Singleton state
|
||||
let sharedChannel: RealtimeChannel | null = null;
|
||||
let sharedConnectionStatus: ConnectionStatus = 'disconnected';
|
||||
const subscribers = new Set<(status: ConnectionStatus) => void>();
|
||||
const statusUpdateCallbacks = new Set<() => void>();
|
||||
//const subscribers: Set<(status: ConnectionStatus) => void> = new Set();
|
||||
//const statusUpdateCallbacks: Set<() => void> = new Set();
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimeout: NodeJS.Timeout | undefined;
|
||||
const supabase = createClient();
|
||||
|
||||
const notifySubscribers = (status: ConnectionStatus) => {
|
||||
sharedConnectionStatus = status;
|
||||
subscribers.forEach(callback => callback(status));
|
||||
};
|
||||
|
||||
const notifyStatusUpdate = () => {
|
||||
statusUpdateCallbacks.forEach(callback => callback());
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = undefined;
|
||||
}
|
||||
|
||||
if (sharedChannel) {
|
||||
supabase.removeChannel(sharedChannel).catch((error) => {
|
||||
console.error('Error removing shared channel:', error);
|
||||
});
|
||||
sharedChannel = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (sharedChannel) return; // Already connected or connecting
|
||||
|
||||
cleanup();
|
||||
notifySubscribers('connecting');
|
||||
|
||||
const channel = supabase
|
||||
.channel('shared_status_updates', {
|
||||
config: { broadcast: {self: true }}
|
||||
})
|
||||
.on('broadcast', { event: 'status_updated' }, () => {
|
||||
notifyStatusUpdate();
|
||||
})
|
||||
.subscribe((status) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
if (status === 'SUBSCRIBED') {
|
||||
notifySubscribers('connected');
|
||||
reconnectAttempts = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CHANNEL_ERROR' || status === 'CLOSED') {
|
||||
notifySubscribers('disconnected');
|
||||
|
||||
if (reconnectAttempts < 5) {
|
||||
reconnectAttempts++;
|
||||
const delay = 2000 * reconnectAttempts;
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
if (subscribers.size > 0) { // Only reconnect if there are active subscribers
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sharedChannel = channel;
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
cleanup();
|
||||
notifySubscribers('disconnected');
|
||||
};
|
||||
|
||||
export const useSharedStatusSubscription = (onStatusUpdate?: () => void) => {
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(sharedConnectionStatus);
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to status changes
|
||||
subscribers.add(setConnectionStatus);
|
||||
|
||||
// Subscribe to status updates
|
||||
if (onStatusUpdate) {
|
||||
statusUpdateCallbacks.add(onStatusUpdate);
|
||||
}
|
||||
|
||||
// Connect if this is the first subscriber
|
||||
if (subscribers.size === 1) {
|
||||
const timeout = setTimeout(connect, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup subscriptions
|
||||
subscribers.delete(setConnectionStatus);
|
||||
if (onStatusUpdate) {
|
||||
statusUpdateCallbacks.delete(onStatusUpdate);
|
||||
}
|
||||
|
||||
// Disconnect if no more subscribers
|
||||
if (subscribers.size === 0) {
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
}, [onStatusUpdate]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
reconnectAttempts = 0;
|
||||
connect();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connectionStatus,
|
||||
connect: reconnect,
|
||||
disconnect,
|
||||
};
|
||||
};
|
132
src/lib/hooks/useStatusData.ts
Normal file
132
src/lib/hooks/useStatusData.ts
Normal file
@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getRecentUsersWithStatuses,
|
||||
updateStatuses,
|
||||
updateUserStatus,
|
||||
type UserWithStatus,
|
||||
} from '@/lib/hooks';
|
||||
import { QueryErrorCodes } from '@/components/context';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type UseStatusDataOptions = {
|
||||
initialData?: UserWithStatus[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const useStatusData = ({
|
||||
initialData = [],
|
||||
enabled = true
|
||||
}: UseStatusDataOptions = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [newStatuses, setNewStatuses] = useState<Set<UserWithStatus>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
const query = 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,
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
initialData,
|
||||
meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED },
|
||||
});
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
usersWithStatuses,
|
||||
status,
|
||||
}: {
|
||||
usersWithStatuses: UserWithStatus[];
|
||||
status: string;
|
||||
}) => {
|
||||
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}`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED },
|
||||
onMutate: async ({ usersWithStatuses, status }) => {
|
||||
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 optimistically updated statuses
|
||||
setNewStatuses((prev) => new Set([...prev, ...usersWithStatuses]));
|
||||
setTimeout(() => {
|
||||
setNewStatuses((prev) => {
|
||||
const updated = new Set(prev);
|
||||
usersWithStatuses.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}'.`
|
||||
);
|
||||
});
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(['users-with-statuses'], context.previousData);
|
||||
}
|
||||
toast.error(`Error updating statuses: ${error}`);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
newStatuses,
|
||||
updateStatusMutation,
|
||||
};
|
||||
};
|
147
src/lib/hooks/useStatusSubscription.ts
Normal file
147
src/lib/hooks/useStatusSubscription.ts
Normal file
@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createClient } from '@/utils/supabase';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
export type ConnectionStatus =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'updating';
|
||||
|
||||
type UseStatusSubscriptionOptions = {
|
||||
enabled?: boolean;
|
||||
onStatusUpdate?: () => void;
|
||||
maxReconnectAttempts?: number;
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
export const useStatusSubscription = ({
|
||||
enabled = true,
|
||||
onStatusUpdate,
|
||||
maxReconnectAttempts = 5,
|
||||
reconnectDelay = 2000,
|
||||
}: UseStatusSubscriptionOptions = {}) => {
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>('disconnected');
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const supabaseRef = useRef(createClient());
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const isComponentMountedRef = useRef(true);
|
||||
const visibilityTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
}
|
||||
if (visibilityTimeoutRef.current) {
|
||||
clearTimeout(visibilityTimeoutRef.current);
|
||||
visibilityTimeoutRef.current = undefined;
|
||||
}
|
||||
if (channelRef.current) {
|
||||
supabaseRef.current.removeChannel(channelRef.current).catch((error) => {
|
||||
console.error('❌ cleanup: Error removing channel:', error);
|
||||
});
|
||||
channelRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!enabled || !isComponentMountedRef.current) return;
|
||||
|
||||
cleanup();
|
||||
setConnectionStatus('connecting');
|
||||
|
||||
const channel = supabaseRef.current
|
||||
.channel('status_updates', {
|
||||
config: { broadcast: {self: true }}
|
||||
});
|
||||
channel
|
||||
.on('broadcast', { event: 'status_updated' }, (payload) => {
|
||||
onStatusUpdate?.();
|
||||
})
|
||||
.subscribe((status) => {
|
||||
if (!isComponentMountedRef.current) return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
if (status === 'SUBSCRIBED') {
|
||||
setConnectionStatus('connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||
} else if (status === 'CHANNEL_ERROR' || status === 'CLOSED') {
|
||||
setConnectionStatus('disconnected');
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const delay = reconnectDelay * reconnectAttemptsRef.current;
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (isComponentMountedRef.current) connect();
|
||||
}, delay);
|
||||
} else {
|
||||
console.warn('⚠️ connect: Max reconnection attempts reached');
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
}, [enabled, onStatusUpdate, maxReconnectAttempts, reconnectDelay, cleanup]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
cleanup();
|
||||
setConnectionStatus('disconnected');
|
||||
}, [cleanup]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
// Handle visibility change for better reconnection
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (!enabled) return;
|
||||
if (document.visibilityState === 'visible') {
|
||||
visibilityTimeoutRef.current = setTimeout(() => {
|
||||
if (connectionStatus === 'disconnected' && isComponentMountedRef.current) {
|
||||
reconnect();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [enabled, connectionStatus, reconnect]);
|
||||
|
||||
// Initial connection - SIMPLIFIED to avoid dependency issues
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
const initialTimeout = setTimeout(() => {
|
||||
if (isComponentMountedRef.current) connect();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialTimeout);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
connectionStatus,
|
||||
connect: reconnect,
|
||||
disconnect,
|
||||
};
|
||||
};
|
@ -4,3 +4,33 @@ import { twMerge } from 'tailwind-merge';
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const makeConditionalClassName = ({
|
||||
context,
|
||||
defaultClassName,
|
||||
on = '',
|
||||
off = '',
|
||||
}: {
|
||||
context: boolean;
|
||||
defaultClassName: string;
|
||||
on?: string;
|
||||
off?: string;
|
||||
}) => {
|
||||
return defaultClassName + ' ' + (context ? on : off);
|
||||
};
|
||||
|
||||
export const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const time = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
return time;
|
||||
};
|
||||
|
||||
export const formatDate = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'long' });
|
||||
return `${month} ${day}`;
|
||||
};
|
||||
|
@ -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,15 @@ 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
|
||||
|
Reference in New Issue
Block a user