Last commit before I burn it all to the ground with ReactQuery
This commit is contained in:
@ -31,6 +31,7 @@
|
|||||||
"@supabase/ssr": "^0.6.1",
|
"@supabase/ssr": "^0.6.1",
|
||||||
"@supabase/supabase-js": "^2.50.0",
|
"@supabase/supabase-js": "^2.50.0",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
|
"@tanstack/react-query": "^5.80.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
@ -48,6 +49,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
"@tailwindcss/postcss": "^4.1.10",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.78.0",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^20.19.0",
|
"@types/node": "^20.19.0",
|
||||||
|
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@ -50,6 +50,9 @@ importers:
|
|||||||
'@t3-oss/env-nextjs':
|
'@t3-oss/env-nextjs':
|
||||||
specifier: ^0.12.0
|
specifier: ^0.12.0
|
||||||
version: 0.12.0(typescript@5.8.3)(zod@3.25.64)
|
version: 0.12.0(typescript@5.8.3)(zod@3.25.64)
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.80.7
|
||||||
|
version: 5.80.7(react@19.1.0)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@ -96,6 +99,9 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.1.10
|
specifier: ^4.1.10
|
||||||
version: 4.1.10
|
version: 4.1.10
|
||||||
|
'@tanstack/eslint-plugin-query':
|
||||||
|
specifier: ^5.78.0
|
||||||
|
version: 5.78.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
|
||||||
'@types/cors':
|
'@types/cors':
|
||||||
specifier: ^2.8.19
|
specifier: ^2.8.19
|
||||||
version: 2.8.19
|
version: 2.8.19
|
||||||
@ -1493,6 +1499,19 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.1.10':
|
'@tailwindcss/postcss@4.1.10':
|
||||||
resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==}
|
resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==}
|
||||||
|
|
||||||
|
'@tanstack/eslint-plugin-query@5.78.0':
|
||||||
|
resolution: {integrity: sha512-hYkhWr3UP0CkAsn/phBVR98UQawbw8CmTSgWtdgEBUjI60/GBaEIkpgi/Bp/2I8eIDK4+vdY7ac6jZx+GR+hEQ==}
|
||||||
|
peerDependencies:
|
||||||
|
eslint: ^8.57.0 || ^9.0.0
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.80.7':
|
||||||
|
resolution: {integrity: sha512-s09l5zeUKC8q7DCCCIkVSns8zZrK4ZDT6ryEjxNBFi68G4z2EBobBS7rdOY3r6W1WbUDpc1fe5oY+YO/+2UVUg==}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.80.7':
|
||||||
|
resolution: {integrity: sha512-u2F0VK6+anItoEvB3+rfvTO9GEh2vb00Je05OwlUe/A0lkJBgW1HckiY3f9YZa+jx6IOe4dHPh10dyp9aY3iRQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@tybys/wasm-util@0.9.0':
|
'@tybys/wasm-util@0.9.0':
|
||||||
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
|
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
|
||||||
|
|
||||||
@ -4965,6 +4984,21 @@ snapshots:
|
|||||||
postcss: 8.5.5
|
postcss: 8.5.5
|
||||||
tailwindcss: 4.1.10
|
tailwindcss: 4.1.10
|
||||||
|
|
||||||
|
'@tanstack/eslint-plugin-query@5.78.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
|
||||||
|
dependencies:
|
||||||
|
'@typescript-eslint/utils': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
|
||||||
|
eslint: 9.29.0(jiti@2.4.2)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
- typescript
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.80.7': {}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.80.7(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.80.7
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.9.0':
|
'@tybys/wasm-util@0.9.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { TechTable } from '@/components/status';
|
import { StatusList, TechTable } from '@/components/status';
|
||||||
import { getUser, getRecentUsersWithStatuses } from '@/lib/actions';
|
import { getUser, getRecentUsersWithStatuses } from '@/lib/actions';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
@ -12,7 +12,8 @@ const Status = async () => {
|
|||||||
const response = await getRecentUsersWithStatuses();
|
const response = await getRecentUsersWithStatuses();
|
||||||
if (!response.success) throw new Error(response.error);
|
if (!response.success) throw new Error(response.error);
|
||||||
const usersWithStatuses = response.data;
|
const usersWithStatuses = response.data;
|
||||||
return <TechTable initialStatuses={usersWithStatuses} />;
|
//return <TechTable initialStatuses={usersWithStatuses} />;
|
||||||
|
return <StatusList initialStatuses={usersWithStatuses} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export default Status;
|
export default Status;
|
||||||
|
@ -48,7 +48,7 @@ export const SignInWithMicrosoft = ({
|
|||||||
if (!updateResponse.success) throw new Error('Could not update provider!');
|
if (!updateResponse.success) throw new Error('Could not update provider!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.location.href = result.data.url;
|
window.location.href = result.data.url + `?provider=${result.data.provider}`;
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
||||||
}
|
}
|
||||||
|
537
src/components/status/StatusList.tsx
Normal file
537
src/components/status/StatusList.tsx
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
"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 { BasedAvatar, Drawer, DrawerTrigger, Loading } from "@/components/ui"
|
||||||
|
import { SubmitButton } from "@/components/default"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { HistoryDrawer } from "@/components/status"
|
||||||
|
import type { Profile } from "@/utils/supabase"
|
||||||
|
import type { RealtimeChannel } from "@supabase/supabase-js"
|
||||||
|
import { makeConditionalClassName } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Wifi, WifiOff, Clock, User } from "lucide-react"
|
||||||
|
|
||||||
|
type StatusListProps = {
|
||||||
|
initialStatuses: UserWithStatus[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusList = ({ initialStatuses = [] }: StatusListProps) => {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { tvMode } = useTVMode()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||||
|
const [selectAll, setSelectAll] = useState(false)
|
||||||
|
const [updatingStatus, setUpdatingStatus] = useState(false)
|
||||||
|
const [statusInput, setStatusInput] = useState("")
|
||||||
|
const [usersWithStatuses, setUsersWithStatuses] = useState<UserWithStatus[]>(initialStatuses)
|
||||||
|
const [selectedHistoryUser, setSelectedHistoryUser] = useState<Profile | null>(null)
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting")
|
||||||
|
const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set())
|
||||||
|
const channelRef = useRef<RealtimeChannel | null>(null)
|
||||||
|
const supabaseRef = useRef(createClient())
|
||||||
|
|
||||||
|
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 {
|
||||||
|
setUpdatingStatus(true)
|
||||||
|
if (selectedUsers.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 selectedUserObjects = usersWithStatuses.filter((u) => selectedUsers.includes(u.user.id))
|
||||||
|
const result = await updateStatuses(selectedUserObjects, statusInput)
|
||||||
|
if (!result.success) throw new Error(result.error)
|
||||||
|
toast.success(`Status updated for ${selectedUsers.length} selected users.`)
|
||||||
|
}
|
||||||
|
setSelectedUsers([])
|
||||||
|
setStatusInput("")
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
toast.error(`Failed to update status: ${errorMessage}`)
|
||||||
|
} finally {
|
||||||
|
setUpdatingStatus(false)
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, statusInput, selectedUsers, usersWithStatuses])
|
||||||
|
|
||||||
|
const handleCheckboxChange = (id: string) => {
|
||||||
|
setSelectedUsers((prev) => (prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAllChange = () => {
|
||||||
|
if (selectAll) {
|
||||||
|
setSelectedUsers([])
|
||||||
|
} else {
|
||||||
|
setSelectedUsers(usersWithStatuses.map((user) => user.user.id))
|
||||||
|
}
|
||||||
|
setSelectAll(!selectAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectAll(selectedUsers.length === usersWithStatuses.length && usersWithStatuses.length > 0)
|
||||||
|
}, [selectedUsers.length, usersWithStatuses.length])
|
||||||
|
|
||||||
|
// Real-time connection setup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
|
||||||
|
let reconnectAttempts = 0
|
||||||
|
const maxReconnectAttempts = 3
|
||||||
|
let reconnectTimeout: NodeJS.Timeout
|
||||||
|
let isComponentMounted = true
|
||||||
|
let currentChannel: RealtimeChannel | null = null
|
||||||
|
|
||||||
|
const setupRealtimeConnection = () => {
|
||||||
|
if (!isComponentMounted) return
|
||||||
|
|
||||||
|
// Clean up any existing channel first
|
||||||
|
if (currentChannel) {
|
||||||
|
supabaseRef.current.removeChannel(currentChannel).catch(console.error)
|
||||||
|
currentChannel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Setting up new realtime connection...")
|
||||||
|
setConnectionStatus("connecting")
|
||||||
|
|
||||||
|
const channel = supabaseRef.current
|
||||||
|
.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)
|
||||||
|
|
||||||
|
// Add animation class for new status
|
||||||
|
setNewStatusIds((prev) => new Set([...prev, user_status.user.id]))
|
||||||
|
|
||||||
|
// Remove animation class after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
setNewStatusIds((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(user_status.user.id)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
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,
|
||||||
|
status: user_status.status,
|
||||||
|
created_at: user_status.created_at,
|
||||||
|
updated_by: user_status.updated_by,
|
||||||
|
}
|
||||||
|
return updatedUsers
|
||||||
|
} else {
|
||||||
|
return [user_status, ...prevUsers]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.subscribe((status) => {
|
||||||
|
console.log("Subscription status:", status)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||||
|
if (status === "SUBSCRIBED") {
|
||||||
|
console.log("Successfully subscribed to status updates!")
|
||||||
|
setConnectionStatus("connected")
|
||||||
|
reconnectAttempts = 0
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||||
|
} else if (status === "CLOSED") {
|
||||||
|
console.log("Connection closed")
|
||||||
|
setConnectionStatus("disconnected")
|
||||||
|
|
||||||
|
if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) {
|
||||||
|
reconnectAttempts++
|
||||||
|
const delay = 2000 * reconnectAttempts
|
||||||
|
|
||||||
|
console.log(`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`)
|
||||||
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
setupRealtimeConnection()
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
} else {
|
||||||
|
console.log("Max reconnection attempts reached or component unmounted")
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||||
|
} else if (status === "CHANNEL_ERROR") {
|
||||||
|
console.error("Channel error - stopping reconnection attempts")
|
||||||
|
setConnectionStatus("disconnected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
currentChannel = channel
|
||||||
|
channelRef.current = channel
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTimeout = setTimeout(() => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
setupRealtimeConnection()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isComponentMounted = false
|
||||||
|
|
||||||
|
if (initialTimeout) {
|
||||||
|
clearTimeout(initialTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChannel) {
|
||||||
|
console.log("Cleaning up realtime connection...")
|
||||||
|
supabaseRef.current.removeChannel(currentChannel).catch((error) => {
|
||||||
|
console.error(`Error unsubscribing: ${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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConnectionIcon = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return <Wifi className="w-4 h-4 text-green-500" />
|
||||||
|
case "connecting":
|
||||||
|
return <Wifi className="w-4 h-4 text-yellow-500 animate-pulse" />
|
||||||
|
case "disconnected":
|
||||||
|
return <WifiOff className="w-4 h-4 text-red-500" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConnectionText = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return "Connected"
|
||||||
|
case "connecting":
|
||||||
|
return "Connecting..."
|
||||||
|
case "disconnected":
|
||||||
|
return "Disconnected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-[400px]">
|
||||||
|
<Loading className="w-full" alpha={0.5} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClassName = makeConditionalClassName({
|
||||||
|
context: tvMode,
|
||||||
|
defaultClassName: "mx-auto space-y-4",
|
||||||
|
on: "lg:w-11/12 w-full mt-15",
|
||||||
|
off: "w-5/6",
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardClassName = makeConditionalClassName({
|
||||||
|
context: tvMode,
|
||||||
|
defaultClassName: "transition-all duration-300 hover:shadow-md",
|
||||||
|
on: "lg:text-4xl",
|
||||||
|
off: "lg:text-base",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
{/* Connection Status Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className={`font-bold ${tvMode ? "text-6xl" : "text-2xl"}`}>Tech Status</h2>
|
||||||
|
<Badge variant="outline" className="flex items-center gap-2">
|
||||||
|
{getConnectionIcon()}
|
||||||
|
<span className="text-xs">{getConnectionText()}</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!tvMode && usersWithStatuses.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="select-all" checked={selectAll} onCheckedChange={handleSelectAllChange} />
|
||||||
|
<label htmlFor="select-all" className="text-sm font-medium">
|
||||||
|
Select All ({selectedUsers.length} selected)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{usersWithStatuses.map((userWithStatus) => {
|
||||||
|
const isSelected = selectedUsers.includes(userWithStatus.user.id)
|
||||||
|
const isNewStatus = newStatusIds.has(userWithStatus.user.id)
|
||||||
|
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" : ""}
|
||||||
|
hover:bg-muted/50 cursor-pointer
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<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.user.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<BasedAvatar
|
||||||
|
src={userWithStatus.user.avatar_url}
|
||||||
|
fullName={userWithStatus.user.full_name}
|
||||||
|
className={tvMode ? "w-16 h-16" : "w-12 h-12"}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-semibold ${tvMode ? "text-5xl" : "text-lg"}`}>
|
||||||
|
{userWithStatus.user.full_name ?? "Unknown User"}
|
||||||
|
</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-3 h-3"
|
||||||
|
/>
|
||||||
|
{userWithStatus.updated_by && (
|
||||||
|
<span className={`text-xs ${tvMode ? "text-3xl" : ""}`}>
|
||||||
|
Updated by {userWithStatus.updated_by.full_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Clock className={`${tvMode ? "w-8 h-8" : "w-4 h-4"}`} />
|
||||||
|
<span className={`text-sm ${tvMode ? "text-3xl" : ""}`}>
|
||||||
|
{formatTime(userWithStatus.created_at)}
|
||||||
|
</span>
|
||||||
|
</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-base"}
|
||||||
|
`}
|
||||||
|
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 yet</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Update Input */}
|
||||||
|
{!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 gap-4">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder="What's your status?"
|
||||||
|
className="flex-1 text-base"
|
||||||
|
value={statusInput}
|
||||||
|
onChange={(e) => setStatusInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
updateStatus().catch((error) => {
|
||||||
|
toast.error(`Failed to update status: ${error as Error}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
onClick={updateStatus}
|
||||||
|
disabled={updatingStatus}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
{selectedUsers.length > 0 ? `Update ${selectedUsers.length} Users` : "Update Status"}
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
{selectedUsers.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Updating status for {selectedUsers.length} selected users</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Status History Drawer */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
//'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 {
|
||||||
|
//BasedAvatar,
|
||||||
|
//Drawer,
|
||||||
|
//DrawerTrigger,
|
||||||
|
//Loading,
|
||||||
|
//} from '@/components/ui';
|
||||||
|
//import { SubmitButton } from '@/components/default';
|
||||||
|
//import { toast } from 'sonner';
|
||||||
|
//import { HistoryDrawer } from '@/components/status';
|
||||||
|
//import type { Profile } from '@/utils/supabase';
|
||||||
|
//import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
//import { makeConditionalClassName } from '@/lib/utils';
|
||||||
|
|
||||||
|
//export const StatusList = (initialStatuses: UserWithStatus[]) => {
|
||||||
|
//const { isAuthenticated } = useAuth();
|
||||||
|
//const { tvMode } = useTVMode();
|
||||||
|
//const [loading, setLoading] = useState(true);
|
||||||
|
//const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
|
||||||
|
//const [selectAll, setSelectAll] = useState(false);
|
||||||
|
//const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||||
|
//const [statusInput, setStatusInput] = useState('');
|
||||||
|
//const [usersWithStatuses, setUsersWithStatuses] = useState<UserWithStatus[]>(initialStatuses);
|
||||||
|
//const [selectedHistoryUser, setSelectedHistoryUser] = useState<UserWithStatus | null>(null);
|
||||||
|
//const channelRef = useRef<RealtimeChannel | null>(null);
|
||||||
|
//const supabaseRef = useRef(createClient());
|
||||||
|
|
||||||
|
//const fetchRecentUsersWithStatuses = useCallback(async () => {
|
||||||
|
//const response = await getRecentUsersWithStatuses();
|
||||||
|
//if (response.success) return response.data;
|
||||||
|
//else toast.error('Error fetching users!');
|
||||||
|
//return [];
|
||||||
|
//}, []);
|
||||||
|
|
||||||
|
//useEffect(() => {
|
||||||
|
//const loadData = async () => {
|
||||||
|
//const data = await fetchRecentUsersWithStatuses();
|
||||||
|
//setUsersWithStatuses(data);
|
||||||
|
//};
|
||||||
|
//loadData()
|
||||||
|
//.catch(()=> toast.error('Error fetching users.'))
|
||||||
|
//.finally(() => setLoading(false));
|
||||||
|
//}, [fetchRecentUsersWithStatuses, isAuthenticated]);
|
||||||
|
|
||||||
|
//const updateStatus = useCallback(async () => {
|
||||||
|
//try {
|
||||||
|
//if (!isAuthenticated) throw new Error('Not authenticated.');
|
||||||
|
//if (!statusInput.trim()) throw new Error('Not a valid status.')
|
||||||
|
|
||||||
|
//setUpdatingStatus(true);
|
||||||
|
//if (selectedUsers.length === 0) {
|
||||||
|
//const result = await updateUserStatus(statusInput);
|
||||||
|
//if (!result.success) throw new Error('Could not updateUserStatus!')
|
||||||
|
//toast.success('Status updated!');
|
||||||
|
//} else {
|
||||||
|
//const result = await updateStatuses(selectedUsers, statusInput);
|
||||||
|
//}
|
||||||
|
|
||||||
|
//} catch (error) {
|
||||||
|
//toast.error(`Error updating statuses: ${error as Error}`);
|
||||||
|
//}
|
||||||
|
//});
|
||||||
|
|
||||||
|
//return (
|
||||||
|
//<div/>
|
||||||
|
//);
|
||||||
|
//};
|
@ -32,14 +32,15 @@ export const TechTable = ({
|
|||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const { tvMode } = useTVMode();
|
const { tvMode } = useTVMode();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
|
||||||
const [selectAll, setSelectAll] = useState(false);
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
const [statusInput, setStatusInput] = useState('');
|
const [statusInput, setStatusInput] = useState('');
|
||||||
const [usersWithStatuses, setUsersWithStatuses] =
|
const [usersWithStatuses, setUsersWithStatuses] =
|
||||||
useState<UserWithStatus[]>(initialStatuses);
|
useState<UserWithStatus[]>(initialStatuses);
|
||||||
const [selectedHistoryUser, setSelectedHistoryUser] =
|
const [selectedHistoryUser, setSelectedHistoryUser] =
|
||||||
useState<Profile | null>(null);
|
useState<UserWithStatus | null>(null);
|
||||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||||
|
const supabaseRef = useRef(createClient());
|
||||||
|
|
||||||
const fetchRecentUsersWithStatuses = useCallback(async () => {
|
const fetchRecentUsersWithStatuses = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -74,28 +75,28 @@ export const TechTable = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (selectedIds.length === 0) {
|
if (selectedUsers.length === 0) {
|
||||||
const result = await updateUserStatus(statusInput);
|
const result = await updateUserStatus(statusInput);
|
||||||
if (!result.success) throw new Error(result.error);
|
if (!result.success) throw new Error(result.error);
|
||||||
toast.success(`Status updated for signed in user.`);
|
toast.success(`Status updated for signed in user.`);
|
||||||
} else {
|
} else {
|
||||||
const result = await updateStatuses(selectedIds, statusInput);
|
const result = await updateStatuses(selectedUsers, statusInput);
|
||||||
if (!result.success) throw new Error(result.error);
|
if (!result.success) throw new Error(result.error);
|
||||||
toast.success(
|
toast.success(
|
||||||
`Status updated for ${selectedIds.length} selected users.`,
|
`Status updated for ${selectedUsers.length} selected users.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setSelectedIds([]);
|
setSelectedUsers([]);
|
||||||
setStatusInput('');
|
setStatusInput('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
toast.error(`Failed to update status: ${errorMessage}`);
|
toast.error(`Failed to update status: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, statusInput, selectedIds]);
|
}, [isAuthenticated, statusInput, selectedUsers]);
|
||||||
|
|
||||||
const handleCheckboxChange = (id: string) => {
|
const handleCheckboxChange = (id: string) => {
|
||||||
setSelectedIds((prev) =>
|
setSelectedUsers((prev) =>
|
||||||
prev.includes(id)
|
prev.includes(id)
|
||||||
? prev.filter((prevId) => prevId !== id)
|
? prev.filter((prevId) => prevId !== id)
|
||||||
: [...prev, id],
|
: [...prev, id],
|
||||||
@ -104,76 +105,142 @@ export const TechTable = ({
|
|||||||
|
|
||||||
const handleSelectAllChange = () => {
|
const handleSelectAllChange = () => {
|
||||||
if (selectAll) {
|
if (selectAll) {
|
||||||
setSelectedIds([]);
|
setSelectedUsers([]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedIds(usersWithStatuses.map((tech) => tech.user.id));
|
setSelectedUsers(usersWithStatuses.map((tech) => tech.user.id));
|
||||||
}
|
}
|
||||||
setSelectAll(!selectAll);
|
setSelectAll(!selectAll);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectAll(
|
setSelectAll(
|
||||||
selectedIds.length === usersWithStatuses.length &&
|
selectedUsers.length === usersWithStatuses.length &&
|
||||||
usersWithStatuses.length > 0,
|
usersWithStatuses.length > 0,
|
||||||
);
|
);
|
||||||
}, [selectedIds.length, usersWithStatuses.length]);
|
}, [selectedUsers.length, usersWithStatuses.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
const supabase = createClient();
|
let reconnectAttempts = 0;
|
||||||
const channel = supabase
|
const maxReconnectAttempts = 3; // Reduced from 5
|
||||||
.channel('status_updates', {
|
let reconnectTimeout: NodeJS.Timeout;
|
||||||
config: { broadcast: { self: true }}
|
let isComponentMounted = true;
|
||||||
})
|
let currentChannel: RealtimeChannel | null = null;
|
||||||
.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 setupRealtimeConnection = () => {
|
||||||
const existingUserIndex = prevUsers.findIndex((u) =>
|
if (!isComponentMounted) return;
|
||||||
u.user.id === user_status.user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingUserIndex !== -1) {
|
// Clean up any existing channel first
|
||||||
const updatedUsers = [...prevUsers];
|
if (currentChannel) {
|
||||||
updatedUsers[existingUserIndex] = {
|
supabaseRef.current.removeChannel(currentChannel).catch(console.error);
|
||||||
user: user_status.user, // Use the user from the broadcast
|
currentChannel = null;
|
||||||
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;
|
console.log('Setting up new realtime connection...');
|
||||||
|
|
||||||
|
const channel = supabaseRef.current
|
||||||
|
.channel(`status_updates`, { // Unique channel name
|
||||||
|
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,
|
||||||
|
status: user_status.status,
|
||||||
|
created_at: user_status.created_at,
|
||||||
|
updated_by: user_status.updated_by,
|
||||||
|
};
|
||||||
|
return updatedUsers;
|
||||||
|
} else {
|
||||||
|
return [user_status, ...prevUsers];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.subscribe((status) => {
|
||||||
|
console.log('Subscription status:', status);
|
||||||
|
// ignore this enum error please
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||||
|
if (status === 'SUBSCRIBED') {
|
||||||
|
console.log('Successfully subscribed to status updates!');
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||||
|
} else if (status === 'CLOSED') {
|
||||||
|
console.log('Connection closed');
|
||||||
|
|
||||||
|
// Only reconnect if we haven't exceeded max attempts and component is still mounted
|
||||||
|
if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
const delay = 2000 * reconnectAttempts; // Linear backoff instead of exponential
|
||||||
|
|
||||||
|
console.log(`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`);
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
setupRealtimeConnection();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.log('Max reconnection attempts reached or component unmounted');
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (status === 'CHANNEL_ERROR') {
|
||||||
|
console.error('Channel error - stopping reconnection attempts');
|
||||||
|
// Don't reconnect on channel errors to avoid infinite loops
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentChannel = channel;
|
||||||
|
channelRef.current = channel;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a small delay before initial connection to ensure auth is stable
|
||||||
|
const initialTimeout = setTimeout(() => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
setupRealtimeConnection();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (channelRef.current) {
|
isComponentMounted = false;
|
||||||
supabase.removeChannel(channelRef.current).catch((error) => {
|
|
||||||
console.error(`Error unsubscribing from status updates: ${error}`);
|
if (initialTimeout) {
|
||||||
});
|
clearTimeout(initialTimeout);
|
||||||
channelRef.current = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChannel) {
|
||||||
|
console.log('Cleaning up realtime connection...');
|
||||||
|
supabaseRef.current.removeChannel(currentChannel).catch((error) => {
|
||||||
|
console.error(`Error unsubscribing: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
channelRef.current = null;
|
||||||
};
|
};
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]); // Keep this dependency but make the connection more stable
|
||||||
|
|
||||||
const formatTime = (timestamp: string) => {
|
const formatTime = (timestamp: string) => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
@ -258,7 +325,7 @@ export const TechTable = ({
|
|||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
className={checkBoxClassName}
|
className={checkBoxClassName}
|
||||||
checked={selectedIds.includes(userWithStatus.user.id)}
|
checked={selectedUsers.includes(userWithStatus.user.id)}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
handleCheckboxChange(userWithStatus.user.id)
|
handleCheckboxChange(userWithStatus.user.id)
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from './HistoryDrawer';
|
export * from './HistoryDrawer';
|
||||||
|
export * from './StatusList';
|
||||||
export * from './TechTable';
|
export * from './TechTable';
|
||||||
|
@ -24,6 +24,16 @@ type PaginatedHistory = {
|
|||||||
export const getRecentUsersWithStatuses = async (): Promise<
|
export const getRecentUsersWithStatuses = async (): Promise<
|
||||||
Result<UserWithStatus[]>
|
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 {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
||||||
@ -45,6 +55,7 @@ export const getRecentUsersWithStatuses = async (): Promise<
|
|||||||
if (error) throw error as Error;
|
if (error) throw error as Error;
|
||||||
if (!data?.length) return { success: true, data: [] };
|
if (!data?.length) return { success: true, data: [] };
|
||||||
|
|
||||||
|
// 3️⃣ client-side dedupe: keep the first status you see per user
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const filtered = data.filter((row) => {
|
const filtered = data.filter((row) => {
|
||||||
if (seen.has(row.user.id)) return false;
|
if (seen.has(row.user.id)) return false;
|
||||||
@ -53,32 +64,16 @@ export const getRecentUsersWithStatuses = async (): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredWithAvatars = new Array<UserWithStatus>();
|
const filteredWithAvatars = new Array<UserWithStatus>();
|
||||||
|
|
||||||
for (const userWithStatus of filtered) {
|
for (const userWithStatus of filtered) {
|
||||||
|
if (userWithStatus.user.avatar_url)
|
||||||
if (userWithStatus.user.avatar_url) {
|
userWithStatus.user.avatar_url =
|
||||||
const avatarResponse = await getSignedUrl({
|
await getAvatarUrl(userWithStatus.updated_by?.avatar_url);
|
||||||
bucket: 'avatars',
|
if (userWithStatus.updated_by?.avatar_url)
|
||||||
url: userWithStatus.user.avatar_url,
|
userWithStatus.user.avatar_url =
|
||||||
});
|
await getAvatarUrl(userWithStatus.updated_by?.avatar_url);
|
||||||
if (avatarResponse.success) {
|
|
||||||
userWithStatus.user.avatar_url = avatarResponse.data;
|
|
||||||
} else userWithStatus.user.avatar_url = null;
|
|
||||||
} else userWithStatus.user.avatar_url = null;
|
|
||||||
|
|
||||||
if (userWithStatus.updated_by?.avatar_url) {
|
|
||||||
const updatedByAvatarResponse = await getSignedUrl({
|
|
||||||
bucket: 'avatars',
|
|
||||||
url: userWithStatus.updated_by.avatar_url ?? '',
|
|
||||||
});
|
|
||||||
if (updatedByAvatarResponse.success) {
|
|
||||||
userWithStatus.updated_by.avatar_url = updatedByAvatarResponse.data;
|
|
||||||
} else userWithStatus.updated_by.avatar_url = null;
|
|
||||||
} else {
|
|
||||||
if (userWithStatus.updated_by) userWithStatus.updated_by.avatar_url = null;
|
|
||||||
}
|
|
||||||
filteredWithAvatars.push(userWithStatus);
|
filteredWithAvatars.push(userWithStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: filteredWithAvatars };
|
return { success: true, data: filteredWithAvatars };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: `Error: ${error as Error}` };
|
return { success: false, error: `Error: ${error as Error}` };
|
||||||
@ -115,41 +110,36 @@ export const broadcastStatusUpdates = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateStatuses = async (
|
export const updateStatuses = async (
|
||||||
userIds: string[],
|
usersWithStatuses: UserWithStatus[],
|
||||||
status: string,
|
status: string,
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const profileResponse = await getProfile();
|
const profileResponse = await getProfile();
|
||||||
if (!profileResponse.success) throw new Error('Not authenticated!');
|
if (!profileResponse.success) throw new Error('Not authenticated!');
|
||||||
const userProfile = profileResponse.data;
|
const user = profileResponse.data;
|
||||||
|
|
||||||
const inserts = userIds.map((userId) => ({
|
const {
|
||||||
user_id: userId,
|
data: insertedStatuses,
|
||||||
status,
|
error: insertedStatusesError
|
||||||
updated_by_id: userProfile.id,
|
} = await supabase
|
||||||
}));
|
.from('statuses')
|
||||||
|
.insert(usersWithStatuses.map((userWithStatus) => ({
|
||||||
|
user_id: userWithStatus.user.id,
|
||||||
|
status,
|
||||||
|
updated_by_id: user.id,
|
||||||
|
})))
|
||||||
|
.select();
|
||||||
|
|
||||||
const { data: insertedStatuses, error: insertedStatusesError } =
|
if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!');
|
||||||
await supabase.from('statuses').insert(inserts).select();
|
else if (insertedStatuses) {
|
||||||
if (insertedStatusesError) throw insertedStatusesError as Error;
|
const createdAtFallback = new Date(Date.now()).toISOString();
|
||||||
|
await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return {
|
||||||
if (insertedStatuses) {
|
user: s.user,
|
||||||
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
|
status: status,
|
||||||
for (const insertedStatus of insertedStatuses) {
|
created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
|
||||||
const profileResponse = await getProfile(insertedStatus.user_id)
|
updated_by: user,
|
||||||
if (!profileResponse.success) throw new Error(profileResponse.error);
|
}}));
|
||||||
const profile = profileResponse.data;
|
|
||||||
if (profile) {
|
|
||||||
broadcastArray.push({
|
|
||||||
user: profile,
|
|
||||||
status: insertedStatus.status,
|
|
||||||
created_at: insertedStatus.created_at,
|
|
||||||
updated_by: userProfile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await broadcastStatusUpdates(broadcastArray);
|
|
||||||
}
|
}
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -181,14 +171,13 @@ export const updateUserStatus = async (
|
|||||||
.single();
|
.single();
|
||||||
if (insertedStatusError) throw insertedStatusError as Error;
|
if (insertedStatusError) throw insertedStatusError as Error;
|
||||||
|
|
||||||
const userStatus: UserWithStatus = {
|
await broadcastStatusUpdates([{
|
||||||
user: userProfile,
|
user: userProfile,
|
||||||
status: insertedStatus.status,
|
status: insertedStatus.status,
|
||||||
created_at: insertedStatus.created_at,
|
created_at: insertedStatus.created_at,
|
||||||
updated_by: userProfile,
|
updated_by: userProfile,
|
||||||
};
|
}]);
|
||||||
|
|
||||||
await broadcastStatusUpdates([userStatus]);
|
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
@ -24,6 +24,16 @@ type PaginatedHistory = {
|
|||||||
export const getRecentUsersWithStatuses = async (): Promise<
|
export const getRecentUsersWithStatuses = async (): Promise<
|
||||||
Result<UserWithStatus[]>
|
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 {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
||||||
@ -54,32 +64,16 @@ export const getRecentUsersWithStatuses = async (): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredWithAvatars = new Array<UserWithStatus>();
|
const filteredWithAvatars = new Array<UserWithStatus>();
|
||||||
|
|
||||||
for (const userWithStatus of filtered) {
|
for (const userWithStatus of filtered) {
|
||||||
|
if (userWithStatus.user.avatar_url)
|
||||||
if (userWithStatus.user.avatar_url) {
|
userWithStatus.user.avatar_url =
|
||||||
const avatarResponse = await getSignedUrl({
|
await getAvatarUrl(userWithStatus.updated_by?.avatar_url);
|
||||||
bucket: 'avatars',
|
if (userWithStatus.updated_by?.avatar_url)
|
||||||
url: userWithStatus.user.avatar_url,
|
userWithStatus.user.avatar_url =
|
||||||
});
|
await getAvatarUrl(userWithStatus.updated_by?.avatar_url);
|
||||||
if (avatarResponse.success) {
|
|
||||||
userWithStatus.user.avatar_url = avatarResponse.data;
|
|
||||||
} else userWithStatus.user.avatar_url = null;
|
|
||||||
} else userWithStatus.user.avatar_url = null;
|
|
||||||
|
|
||||||
if (userWithStatus.updated_by?.avatar_url) {
|
|
||||||
const updatedByAvatarResponse = await getSignedUrl({
|
|
||||||
bucket: 'avatars',
|
|
||||||
url: userWithStatus.updated_by.avatar_url ?? '',
|
|
||||||
});
|
|
||||||
if (updatedByAvatarResponse.success) {
|
|
||||||
userWithStatus.updated_by.avatar_url = updatedByAvatarResponse.data;
|
|
||||||
} else userWithStatus.updated_by.avatar_url = null;
|
|
||||||
} else {
|
|
||||||
if (userWithStatus.updated_by) userWithStatus.updated_by.avatar_url = null;
|
|
||||||
}
|
|
||||||
filteredWithAvatars.push(userWithStatus);
|
filteredWithAvatars.push(userWithStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: filteredWithAvatars };
|
return { success: true, data: filteredWithAvatars };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: `Error: ${error as Error}` };
|
return { success: false, error: `Error: ${error as Error}` };
|
||||||
@ -116,41 +110,36 @@ export const broadcastStatusUpdates = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateStatuses = async (
|
export const updateStatuses = async (
|
||||||
userIds: string[],
|
usersWithStatuses: UserWithStatus[],
|
||||||
status: string,
|
status: string,
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const profileResponse = await getProfile();
|
const profileResponse = await getProfile();
|
||||||
if (!profileResponse.success) throw new Error('Not authenticated!');
|
if (!profileResponse.success) throw new Error('Not authenticated!');
|
||||||
const userProfile = profileResponse.data;
|
const user = profileResponse.data;
|
||||||
|
|
||||||
const inserts = userIds.map((userId) => ({
|
const {
|
||||||
user_id: userId,
|
data: insertedStatuses,
|
||||||
status,
|
error: insertedStatusesError
|
||||||
updated_by_id: userProfile.id,
|
} = await supabase
|
||||||
}));
|
.from('statuses')
|
||||||
|
.insert(usersWithStatuses.map((userWithStatus) => ({
|
||||||
|
user_id: userWithStatus.user.id,
|
||||||
|
status,
|
||||||
|
updated_by_id: user.id,
|
||||||
|
})))
|
||||||
|
.select();
|
||||||
|
|
||||||
const { data: insertedStatuses, error: insertedStatusesError } =
|
if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!');
|
||||||
await supabase.from('statuses').insert(inserts).select();
|
else if (insertedStatuses) {
|
||||||
if (insertedStatusesError) throw insertedStatusesError as Error;
|
const createdAtFallback = new Date(Date.now()).toISOString();
|
||||||
|
await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return {
|
||||||
if (insertedStatuses) {
|
user: s.user,
|
||||||
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
|
status: status,
|
||||||
for (const insertedStatus of insertedStatuses) {
|
created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
|
||||||
const profileResponse = await getProfile(insertedStatus.user_id)
|
updated_by: user,
|
||||||
if (!profileResponse.success) throw new Error(profileResponse.error);
|
}}));
|
||||||
const profile = profileResponse.data;
|
|
||||||
if (profile) {
|
|
||||||
broadcastArray.push({
|
|
||||||
user: profile,
|
|
||||||
status: insertedStatus.status,
|
|
||||||
created_at: insertedStatus.created_at,
|
|
||||||
updated_by: userProfile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await broadcastStatusUpdates(broadcastArray);
|
|
||||||
}
|
}
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -182,14 +171,13 @@ export const updateUserStatus = async (
|
|||||||
.single();
|
.single();
|
||||||
if (insertedStatusError) throw insertedStatusError as Error;
|
if (insertedStatusError) throw insertedStatusError as Error;
|
||||||
|
|
||||||
const userStatus: UserWithStatus = {
|
await broadcastStatusUpdates([{
|
||||||
user: userProfile,
|
user: userProfile,
|
||||||
status: insertedStatus.status,
|
status: insertedStatus.status,
|
||||||
created_at: insertedStatus.created_at,
|
created_at: insertedStatus.created_at,
|
||||||
updated_by: userProfile,
|
updated_by: userProfile,
|
||||||
};
|
}]);
|
||||||
|
|
||||||
await broadcastStatusUpdates([userStatus]);
|
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
Reference in New Issue
Block a user