Compare commits
3 Commits
d0533b78e8
...
bb0dd14ec9
Author | SHA1 | Date | |
---|---|---|---|
bb0dd14ec9 | |||
3001f90a48 | |||
219549d35a |
@ -1,16 +1,16 @@
|
||||
services:
|
||||
t3-template:
|
||||
techtracker-next:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: docker/development/Dockerfile
|
||||
image: with-docker-multi-env-development
|
||||
container_name: t3-template
|
||||
container_name: techtracker-next
|
||||
networks:
|
||||
- nginx-bridge
|
||||
#ports:
|
||||
#- '3000:3000'
|
||||
- techtracker
|
||||
ports:
|
||||
- '3111:3000'
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
nginx-bridge:
|
||||
techtracker:
|
||||
external: true
|
||||
|
@ -1,16 +1,16 @@
|
||||
services:
|
||||
t3-template:
|
||||
techtracker-next:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: docker/production/Dockerfile
|
||||
image: with-docker-multi-env-development
|
||||
container_name: t3-template
|
||||
container_name: techtracker-next
|
||||
networks:
|
||||
- nginx-bridge
|
||||
#ports:
|
||||
#- '3000:3000'
|
||||
- techtracker
|
||||
ports:
|
||||
- '3111:3000'
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
nginx-bridge:
|
||||
techtracker:
|
||||
external: true
|
||||
|
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
import { useAuth, useTVMode } from '@/components/context';
|
||||
import type { UserWithStatus } from '@/lib/hooks';
|
||||
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
|
||||
@ -7,11 +8,10 @@ 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 { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Clock, Calendar } from 'lucide-react';
|
||||
import { RefreshCw, Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
||||
import { useStatusData, useStatusSubscription } from '@/lib/hooks';
|
||||
import { formatTime, formatDate } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
@ -43,7 +43,6 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// In your StatusList component
|
||||
const { connectionStatus, connect: reconnect } = useStatusSubscription(() => {
|
||||
refetch().catch((error) => {
|
||||
console.error('Error refetching statuses:', error);
|
||||
@ -75,7 +74,12 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
setUpdateStatusMessage('');
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (user: UserWithStatus) => {
|
||||
const handleCardSelect = (user: UserWithStatus, e: React.MouseEvent) => {
|
||||
// Prevent selection if clicking on profile elements
|
||||
if ((e.target as HTMLElement).closest('[data-profile-trigger]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedUsers((prev) =>
|
||||
prev.some((u) => u.user.id === user.user.id)
|
||||
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
|
||||
@ -121,54 +125,46 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
|
||||
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',
|
||||
defaultClassName:
|
||||
'flex flex-col mx-auto items-center\
|
||||
sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2 min-w-[450px]',
|
||||
on: 'mt-8',
|
||||
off: 'px-10',
|
||||
});
|
||||
|
||||
const headerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'w-full',
|
||||
on: 'hidden',
|
||||
off: 'flex mb-4 justify-between',
|
||||
off: 'flex mb-3 justify-between items-center',
|
||||
});
|
||||
|
||||
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',
|
||||
defaultClassName: 'w-full space-y-2',
|
||||
});
|
||||
|
||||
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'
|
||||
<div className='flex items-center gap-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleSelectAllChange}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<CheckCircle2
|
||||
className={`w-4 h-4 ${selectAll ? 'text-primary' : ''}`}
|
||||
/>
|
||||
<label htmlFor='select-all' className='font-medium'>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
{selectAll ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
{!tvMode && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
<p>Miss the old table?</p>
|
||||
<div className='flex items-center gap-2 text-xs'>
|
||||
<span className='text-muted-foreground'>Miss the old table?</span>
|
||||
<Link
|
||||
href='/status/table'
|
||||
className='italic font-semibold text-accent-foreground'
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
Find it here!
|
||||
</Link>
|
||||
@ -198,88 +194,119 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
<Card
|
||||
key={userWithStatus.user.id}
|
||||
className={`
|
||||
${cardClassName}
|
||||
${isSelected ? 'ring-2 ring-primary' : ''}
|
||||
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
|
||||
relative transition-all duration-200 cursor-pointer hover:shadow-md
|
||||
${tvMode ? 'p-4' : 'p-3'}
|
||||
${isSelected ? 'ring-2 ring-primary bg-primary/5 shadow-md' : 'hover:bg-muted/30'}
|
||||
${isNewStatus ? 'animate-in slide-in-from-top-2 duration-500 bg-green-50 border-green-200' : ''}
|
||||
`}
|
||||
onClick={(e) => handleCardSelect(userWithStatus, e)}
|
||||
>
|
||||
<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()}
|
||||
{isSelected && (
|
||||
<div className='absolute top-2 right-2 text-primary'>
|
||||
<CheckCircle2
|
||||
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||
/>
|
||||
)}
|
||||
<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'>
|
||||
|
||||
<CardContent className='p-0'>
|
||||
<div className='flex items-start gap-3'>
|
||||
{/* Profile Section - Clickable for history */}
|
||||
<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'}
|
||||
`}
|
||||
data-profile-trigger
|
||||
className='flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity'
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
<p className='font-medium'>{userWithStatus.status}</p>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.user.avatar_url}
|
||||
fullName={userWithStatus.user.full_name}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className='flex-1'>
|
||||
{/* Header with name and timestamp */}
|
||||
<div className='flex items-start justify-between mb-2'>
|
||||
<div>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<h3
|
||||
data-profile-trigger
|
||||
className={`
|
||||
font-semibold cursor-pointer hover:text-primary/80 truncate
|
||||
${tvMode ? 'text-3xl' : 'text-2xl'}
|
||||
`}
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
{userWithStatus.user.full_name}
|
||||
</h3>
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
<div
|
||||
className={`pl-2 pr-15 pt-2 ${tvMode ? 'text-2xl' : 'text-xl'}`}
|
||||
>
|
||||
<p>{userWithStatus.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-end px-2 gap-2 text-muted-foreground flex-shrink-0'>
|
||||
<div className='flex items-center gap-2 flex-shrink-0 w-full'>
|
||||
<Clock
|
||||
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-2xl' : 'text-xl'}`}
|
||||
>
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 flex-shrink-0 w-full'>
|
||||
<Calendar
|
||||
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-2xl' : 'text-xl'}`}
|
||||
>
|
||||
{formatDate(userWithStatus.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 flex-shrink-0'>
|
||||
{isUpdatedByOther && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.updated_by?.avatar_url}
|
||||
fullName={userWithStatus.updated_by?.full_name}
|
||||
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-base' : 'text-sm'}`}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<p>Updated by</p>
|
||||
{userWithStatus.updated_by?.full_name}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@ -289,7 +316,7 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<Card className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
|
||||
className={`text-muted-foreground ${tvMode ? 'text-2xl' : 'text-lg'}`}
|
||||
>
|
||||
No status updates have been made in the past day.
|
||||
</p>
|
||||
@ -305,8 +332,8 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
<Input
|
||||
autoFocus
|
||||
type='text'
|
||||
placeholder='Enter status'
|
||||
className='flex-1 text-base'
|
||||
placeholder='Enter status update...'
|
||||
className='flex-1 text-2xl'
|
||||
value={statusInput}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
onChange={(e) => setStatusInput(e.target.value)}
|
||||
@ -327,9 +354,8 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
className='px-6'
|
||||
>
|
||||
{selectedUsers.length > 0
|
||||
? `Update status for ${selectedUsers.length}
|
||||
${selectedUsers.length > 1 ? 'users' : 'user'}`
|
||||
: 'Update status'}
|
||||
? `Update ${selectedUsers.length} ${selectedUsers.length > 1 ? 'users' : 'user'}`
|
||||
: 'Update Status'}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
{updateStatusMessage &&
|
||||
@ -347,7 +373,7 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={tvMode ? 'text-3xl p-6' : ''}
|
||||
className={tvMode ? 'text-xl p-6' : ''}
|
||||
>
|
||||
View All Status History
|
||||
</Button>
|
||||
|
@ -1,388 +0,0 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type React 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 } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
||||
import { useStatusData, useStatusSubscription } 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,
|
||||
});
|
||||
|
||||
const { connectionStatus, connect: reconnect } = useStatusSubscription(() => {
|
||||
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 handleCardSelect = (user: UserWithStatus, e: React.MouseEvent) => {
|
||||
// Prevent selection if clicking on profile elements
|
||||
if ((e.target as HTMLElement).closest('[data-profile-trigger]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
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-3 items-center',
|
||||
on: 'lg:w-11/12 w-full mt-8',
|
||||
off: 'sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2',
|
||||
});
|
||||
|
||||
const headerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'w-full',
|
||||
on: 'hidden',
|
||||
off: 'flex mb-6 justify-between items-center',
|
||||
});
|
||||
|
||||
const cardContainerClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'w-full',
|
||||
on: 'grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4',
|
||||
off: 'space-y-2 w-full',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={headerClassName}>
|
||||
<div className='flex items-center gap-6'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleSelectAllChange}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<CheckCircle2
|
||||
className={`w-4 h-4 ${selectAll ? 'text-primary' : ''}`}
|
||||
/>
|
||||
{selectAll ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
{!tvMode && (
|
||||
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
|
||||
<span>Miss the old table?</span>
|
||||
<Link
|
||||
href='/status/table'
|
||||
className='font-medium text-primary hover:underline'
|
||||
>
|
||||
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={`
|
||||
relative transition-all duration-200 cursor-pointer hover:shadow-md
|
||||
${tvMode ? 'p-4' : 'p-3'}
|
||||
${isSelected ? 'ring-2 ring-primary bg-primary/5 shadow-md' : 'hover:bg-muted/30'}
|
||||
${isNewStatus ? 'animate-in slide-in-from-top-2 duration-500 bg-green-50 border-green-200' : ''}
|
||||
`}
|
||||
onClick={(e) => handleCardSelect(userWithStatus, e)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className='absolute top-2 right-2 text-primary'>
|
||||
<CheckCircle2
|
||||
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className='p-0'>
|
||||
<div className='flex items-start gap-3'>
|
||||
{/* Profile Section - Clickable for history */}
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div
|
||||
data-profile-trigger
|
||||
className='flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity'
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.user.avatar_url}
|
||||
fullName={userWithStatus.user.full_name}
|
||||
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||
/>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
{/* Header with name and timestamp */}
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<h3
|
||||
data-profile-trigger
|
||||
className={`
|
||||
font-semibold cursor-pointer hover:underline truncate
|
||||
${tvMode ? 'text-2xl' : 'text-base'}
|
||||
`}
|
||||
onClick={() =>
|
||||
setSelectedHistoryUser(userWithStatus.user)
|
||||
}
|
||||
>
|
||||
{userWithStatus.user.full_name}
|
||||
</h3>
|
||||
</DrawerTrigger>
|
||||
{selectedHistoryUser === userWithStatus.user && (
|
||||
<HistoryDrawer user={selectedHistoryUser} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<div className='flex items-center gap-2 text-muted-foreground flex-shrink-0'>
|
||||
<Clock
|
||||
className={`${tvMode ? 'w-5 h-5' : 'w-4 h-4'}`}
|
||||
/>
|
||||
<span className={`${tvMode ? 'text-lg' : 'text-sm'}`}>
|
||||
{formatTime(userWithStatus.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Content */}
|
||||
<div
|
||||
className={`
|
||||
mb-2 leading-relaxed
|
||||
${tvMode ? 'text-xl' : 'text-sm'}
|
||||
`}
|
||||
>
|
||||
<p>{userWithStatus.status}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer with date and updated by info */}
|
||||
<div className='flex items-center justify-between text-muted-foreground'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar
|
||||
className={`${tvMode ? 'w-4 h-4' : 'w-3 h-3'}`}
|
||||
/>
|
||||
<span className={`${tvMode ? 'text-base' : 'text-xs'}`}>
|
||||
{formatDate(userWithStatus.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isUpdatedByOther && (
|
||||
<div className='flex items-center gap-1'>
|
||||
<BasedAvatar
|
||||
src={userWithStatus.updated_by?.avatar_url}
|
||||
fullName={userWithStatus.updated_by?.full_name}
|
||||
className={`${tvMode ? 'w-5 h-5' : 'w-4 h-4'}`}
|
||||
/>
|
||||
<span
|
||||
className={`${tvMode ? 'text-base' : 'text-xs'}`}
|
||||
>
|
||||
Updated by {userWithStatus.updated_by?.full_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{usersWithStatuses.length === 0 && (
|
||||
<Card className='p-8 text-center'>
|
||||
<p
|
||||
className={`text-muted-foreground ${tvMode ? 'text-2xl' : 'text-base'}`}
|
||||
>
|
||||
No status updates have been made in the past day.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!tvMode && (
|
||||
<Card className='p-6 mt-6 w-full'>
|
||||
<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 update...'
|
||||
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 ${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-xl p-6' : ''}
|
||||
>
|
||||
View All Status History
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<HistoryDrawer />
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -139,14 +139,14 @@ export const TechTable = ({ initialStatuses = [] }: TableProps) => {
|
||||
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]',
|
||||
on: 'lg:text-5xl xl:min-w-[420px]',
|
||||
off: 'lg:text-4xl xl:min-w-[320px]',
|
||||
});
|
||||
const tdClassName = makeConditionalClassName({
|
||||
context: tvMode,
|
||||
defaultClassName: 'py-2 px-2 border',
|
||||
on: 'lg:text-5xl',
|
||||
off: 'lg:text-4xl',
|
||||
on: 'lg:text-4xl',
|
||||
off: 'lg:text-3xl',
|
||||
});
|
||||
const tCheckboxClassName = `py-3 px-4 border`;
|
||||
const checkBoxClassName = `lg:scale-200 cursor-pointer`;
|
||||
@ -161,13 +161,13 @@ export const TechTable = ({ initialStatuses = [] }: TableProps) => {
|
||||
showAsButton={connectionStatus === 'disconnected'}
|
||||
/>
|
||||
{!tvMode && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
<p>Tired of the old table? </p>
|
||||
<div className='flex flex-row gap-2 text-xs'>
|
||||
<p className='text-muted-foreground'>Tired of the old table? </p>
|
||||
<Link
|
||||
href='/status/list'
|
||||
className='italic font-semibold text-accent-foreground'
|
||||
className='italic font-semibold hover:text-primary/80'
|
||||
>
|
||||
Try out the new status list!
|
||||
Try the new status list!
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@ -248,7 +248,7 @@ export const TechTable = ({ initialStatuses = [] }: TableProps) => {
|
||||
fullName={userWithStatus.updated_by?.full_name}
|
||||
className='w-5 h-5'
|
||||
/>
|
||||
<span className={tvMode ? 'text-lg' : 'text-base'}>
|
||||
<span className={tvMode ? 'text-xl' : 'text-base'}>
|
||||
Updated by {userWithStatus.updated_by.full_name}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -1,5 +1,4 @@
|
||||
export * from './ConnectionStatus';
|
||||
export * from './HistoryDrawer';
|
||||
//export * from './List';
|
||||
export * from './StatusList';
|
||||
export * from './List';
|
||||
export * from './Table';
|
||||
|
@ -110,6 +110,10 @@ AZURE_REDIRECT_URI=
|
||||
AZURE_TENANT_ID=
|
||||
AZURE_TENANT_URL=
|
||||
|
||||
# Gib's Auth (Trying to set up Authentik)
|
||||
#SAML_ENABLED=false
|
||||
#SAML_PRIVATE_KEY=
|
||||
|
||||
|
||||
############
|
||||
# Studio - Configuration for the Dashboard
|
||||
|
@ -5,22 +5,22 @@
|
||||
# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
|
||||
# Reset everything: ./reset.sh
|
||||
|
||||
name: supabase
|
||||
name: techtracker
|
||||
|
||||
networks:
|
||||
supabase-network:
|
||||
name: supabase-network
|
||||
techtracker:
|
||||
name: techtracker
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.19.0.0/16
|
||||
|
||||
services:
|
||||
|
||||
studio:
|
||||
container_name: supabase-studio
|
||||
image: supabase/studio:2025.05.19-sha-3487831
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
@ -61,7 +61,7 @@ services:
|
||||
kong:
|
||||
container_name: supabase-kong
|
||||
image: kong:2.8.1
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${KONG_HTTP_PORT}:8000/tcp
|
||||
@ -90,7 +90,7 @@ services:
|
||||
auth:
|
||||
container_name: supabase-auth
|
||||
image: supabase/gotrue:v2.172.1
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
@ -173,7 +173,7 @@ services:
|
||||
GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
|
||||
GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET}
|
||||
GOTRUE_EXTERNAL_AZURE_TENANT_ID: ${AZURE_TENANT_ID}
|
||||
GOTRUE_EXTERNAL_AZURE_TENANT_URL: ${AZURE_TENANT_URL}
|
||||
GOTRUE_EXTERNAL_AZURE_URL: ${AZURE_TENANT_URL}
|
||||
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI}
|
||||
|
||||
# Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook
|
||||
@ -199,7 +199,7 @@ services:
|
||||
rest:
|
||||
container_name: supabase-rest
|
||||
image: postgrest/postgrest:v12.2.12
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
@ -224,7 +224,7 @@ services:
|
||||
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
|
||||
container_name: realtime-dev.supabase-realtime
|
||||
image: supabase/realtime:v2.34.47
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
@ -270,7 +270,7 @@ services:
|
||||
storage:
|
||||
container_name: supabase-storage
|
||||
image: supabase/storage-api:v1.22.17
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage:z
|
||||
@ -314,7 +314,7 @@ services:
|
||||
imgproxy:
|
||||
container_name: supabase-imgproxy
|
||||
image: darthsim/imgproxy:v3.8.0
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage:z
|
||||
@ -337,7 +337,7 @@ services:
|
||||
meta:
|
||||
container_name: supabase-meta
|
||||
image: supabase/postgres-meta:v0.89.0
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
@ -356,7 +356,7 @@ services:
|
||||
functions:
|
||||
container_name: supabase-edge-functions
|
||||
image: supabase/edge-runtime:v1.67.4
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/functions:/home/deno/functions:Z
|
||||
@ -381,7 +381,7 @@ services:
|
||||
analytics:
|
||||
container_name: supabase-analytics
|
||||
image: supabase/logflare:1.12.0
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 4000:4000
|
||||
@ -430,7 +430,7 @@ services:
|
||||
db:
|
||||
container_name: supabase-db
|
||||
image: supabase/postgres:15.8.1.060
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
ports:
|
||||
- ${POSTGRES_PORT}:${POSTGRES_PORT}
|
||||
restart: unless-stopped
|
||||
@ -490,7 +490,7 @@ services:
|
||||
vector:
|
||||
container_name: supabase-vector
|
||||
image: timberio/vector:0.28.1-alpine
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
|
||||
@ -522,7 +522,7 @@ services:
|
||||
supavisor:
|
||||
container_name: supabase-pooler
|
||||
image: supabase/supavisor:2.5.1
|
||||
networks: [supabase-network]
|
||||
networks: [techtracker]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
#- ${POSTGRES_PORT}:5432
|
||||
@ -574,3 +574,4 @@ services:
|
||||
|
||||
volumes:
|
||||
db-config:
|
||||
name: techtracker-db-config
|
||||
|
@ -0,0 +1,126 @@
|
||||
-- Create a table for public profiles
|
||||
create table profiles (
|
||||
id uuid references auth.users on delete cascade not null primary key,
|
||||
updated_at timestamp with time zone,
|
||||
email text unique,
|
||||
full_name text,
|
||||
avatar_url text,
|
||||
provider text,
|
||||
|
||||
constraint full_name_length check (char_length(full_name) >= 3 and char_length(full_name) <= 50)
|
||||
);
|
||||
-- Set up Row Level Security (RLS)
|
||||
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
|
||||
alter table profiles
|
||||
enable row level security;
|
||||
|
||||
create policy "Public profiles are viewable by everyone." on profiles
|
||||
for select using (true);
|
||||
|
||||
create policy "Users can insert their own profile." on profiles
|
||||
for insert with check ((select auth.uid()) = id);
|
||||
|
||||
create policy "Users can update own profile." on profiles
|
||||
for update using ((select auth.uid()) = id);
|
||||
|
||||
-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
|
||||
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
|
||||
create function public.handle_new_user()
|
||||
returns trigger
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
insert into public.profiles (id, email, full_name, avatar_url, provider, updated_at)
|
||||
values (
|
||||
new.id,
|
||||
new.email,
|
||||
new.raw_user_meta_data->>'full_name',
|
||||
new.raw_user_meta_data->>'avatar_url',
|
||||
new.raw_user_meta_data->>'provider',
|
||||
now()
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute procedure public.handle_new_user();
|
||||
|
||||
-- Set up Storage!
|
||||
insert into storage.buckets (id, name)
|
||||
values ('avatars', 'avatars');
|
||||
|
||||
-- Set up access controls for storage.
|
||||
-- See https://supabase.com/docs/guides/storage#policy-examples for more details.
|
||||
create policy "Avatar images are publicly accessible." on storage.objects
|
||||
for select using (bucket_id = 'avatars');
|
||||
|
||||
create policy "Anyone can upload an avatar." on storage.objects
|
||||
for insert with check (bucket_id = 'avatars');
|
||||
|
||||
create policy "Anyone can update an avatar." on storage.objects
|
||||
for update using (bucket_id = 'avatars');
|
||||
|
||||
create policy "Anyone can delete an avatar." on storage.objects
|
||||
for delete using (bucket_id = 'avatars');
|
||||
|
||||
-- Create a table for public statuses
|
||||
CREATE TABLE statuses (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id uuid REFERENCES public.profiles ON DELETE CASCADE NOT NULL,
|
||||
updated_by_id uuid REFERENCES public.profiles ON DELETE SET NULL DEFAULT auth.uid(),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
status text NOT NULL,
|
||||
CONSTRAINT status_length CHECK (char_length(status) >= 3 AND char_length(status) <= 80)
|
||||
);
|
||||
|
||||
-- Set up Row Level Security (RLS)
|
||||
ALTER TABLE statuses
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
CREATE POLICY "Public statuses are viewable by everyone." ON statuses
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- RECREATE it using the recommended sub-select form
|
||||
CREATE POLICY "Authenticated users can insert statuses for any user."
|
||||
ON public.statuses
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
(SELECT auth.role()) = 'authenticated'
|
||||
);
|
||||
|
||||
-- ADD an UPDATE policy so anyone signed-in can update *any* status
|
||||
CREATE POLICY "Authenticated users can update statuses for any user."
|
||||
ON public.statuses
|
||||
FOR UPDATE
|
||||
USING (
|
||||
(SELECT auth.role()) = 'authenticated'
|
||||
)
|
||||
WITH CHECK (
|
||||
(SELECT auth.role()) = 'authenticated'
|
||||
);
|
||||
|
||||
-- Function to add first status
|
||||
CREATE FUNCTION public.handle_first_status()
|
||||
RETURNS TRIGGER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.statuses (user_id, updated_by_id, status)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.id,
|
||||
'Just joined!'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create a separate trigger for the status
|
||||
CREATE TRIGGER on_auth_user_created_add_status
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE PROCEDURE public.handle_first_status();
|
||||
|
||||
alter publication supabase_realtime add table profiles;
|
||||
alter publication supabase_realtime add table statuses;
|
||||
|
Reference in New Issue
Block a user