Compare commits

..

3 Commits

9 changed files with 308 additions and 540 deletions

View File

@@ -1,16 +1,16 @@
services: services:
t3-template: techtracker-next:
build: build:
context: ../../../ context: ../../../
dockerfile: docker/development/Dockerfile dockerfile: docker/development/Dockerfile
image: with-docker-multi-env-development image: with-docker-multi-env-development
container_name: t3-template container_name: techtracker-next
networks: networks:
- nginx-bridge - techtracker
#ports: ports:
#- '3000:3000' - '3111:3000'
tty: true tty: true
restart: unless-stopped restart: unless-stopped
networks: networks:
nginx-bridge: techtracker:
external: true external: true

View File

@@ -1,16 +1,16 @@
services: services:
t3-template: techtracker-next:
build: build:
context: ../../../ context: ../../../
dockerfile: docker/production/Dockerfile dockerfile: docker/production/Dockerfile
image: with-docker-multi-env-development image: with-docker-multi-env-development
container_name: t3-template container_name: techtracker-next
networks: networks:
- nginx-bridge - techtracker
#ports: ports:
#- '3000:3000' - '3111:3000'
tty: true tty: true
restart: unless-stopped restart: unless-stopped
networks: networks:
nginx-bridge: techtracker:
external: true external: true

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type React from 'react';
import { useAuth, useTVMode } from '@/components/context'; import { useAuth, useTVMode } from '@/components/context';
import type { UserWithStatus } from '@/lib/hooks'; import type { UserWithStatus } from '@/lib/hooks';
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui'; 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 { ConnectionStatus, HistoryDrawer } from '@/components/status';
import type { Profile } from '@/utils/supabase'; import type { Profile } from '@/utils/supabase';
import { makeConditionalClassName } from '@/lib/utils'; import { makeConditionalClassName } from '@/lib/utils';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; 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 { useStatusData, useStatusSubscription } from '@/lib/hooks';
import { formatTime, formatDate } from '@/lib/utils'; import { formatTime, formatDate } from '@/lib/utils';
import Link from 'next/link'; import Link from 'next/link';
@@ -43,7 +43,6 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
enabled: isAuthenticated, enabled: isAuthenticated,
}); });
// In your StatusList component
const { connectionStatus, connect: reconnect } = useStatusSubscription(() => { const { connectionStatus, connect: reconnect } = useStatusSubscription(() => {
refetch().catch((error) => { refetch().catch((error) => {
console.error('Error refetching statuses:', error); console.error('Error refetching statuses:', error);
@@ -75,7 +74,12 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
setUpdateStatusMessage(''); 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) => setSelectedUsers((prev) =>
prev.some((u) => u.user.id === user.user.id) prev.some((u) => u.user.id === user.user.id)
? prev.filter((prevUser) => prevUser.user.id !== user.user.id) ? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
@@ -121,54 +125,46 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
const containerClassName = makeConditionalClassName({ const containerClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: 'flex flex-col mx-auto space-y-4 items-center', defaultClassName:
on: 'lg:w-11/12 w-full mt-15', 'flex flex-col mx-auto items-center\
off: 'sm:w-5/6 md:3/4 lg:w-1/2', 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({ const headerClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: 'w-full', defaultClassName: 'w-full',
on: 'hidden', on: 'hidden',
off: 'flex mb-4 justify-between', off: 'flex mb-3 justify-between items-center',
}); });
const cardContainerClassName = makeConditionalClassName({ const cardContainerClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: '', defaultClassName: 'w-full space-y-2',
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 ( return (
<div className={containerClassName}> <div className={containerClassName}>
<div className={headerClassName}> <div className={headerClassName}>
<div className='flex items-center gap-10'> <div className='flex items-center gap-4'>
<div className='flex gap-2'> <Button
<Checkbox variant='outline'
id='select-all' size='sm'
checked={selectAll} onClick={handleSelectAllChange}
onCheckedChange={handleSelectAllChange} className='flex items-center gap-2'
className='size-6' >
<CheckCircle2
className={`w-4 h-4 ${selectAll ? 'text-primary' : ''}`}
/> />
<label htmlFor='select-all' className='font-medium'> {selectAll ? 'Deselect All' : 'Select All'}
Select All </Button>
</label>
</div>
{!tvMode && ( {!tvMode && (
<div className='flex flex-row gap-2'> <div className='flex items-center gap-2 text-xs'>
<p>Miss the old table?</p> <span className='text-muted-foreground'>Miss the old table?</span>
<Link <Link
href='/status/table' href='/status/table'
className='italic font-semibold text-accent-foreground' className='font-medium hover:underline'
> >
Find it here! Find it here!
</Link> </Link>
@@ -198,88 +194,119 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
<Card <Card
key={userWithStatus.user.id} key={userWithStatus.user.id}
className={` className={`
${cardClassName} relative transition-all duration-200 cursor-pointer hover:shadow-md
${isSelected ? 'ring-2 ring-primary' : ''} ${tvMode ? 'p-4' : 'p-3'}
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''} ${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'> {isSelected && (
<div className='flex items-center justify-between'> <div className='absolute top-2 right-2 text-primary'>
<div className='flex items-center gap-3'> <CheckCircle2
{!tvMode && ( className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
<Checkbox />
checked={isSelected} </div>
onCheckedChange={() => )}
handleCheckboxChange(userWithStatus)
<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)
} }
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} <BasedAvatar
</h3> src={userWithStatus.user.avatar_url}
{isUpdatedByOther && ( fullName={userWithStatus.user.full_name}
<div className='flex items-center gap-1 text-muted-foreground'> className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
<BasedAvatar />
src={userWithStatus.updated_by?.avatar_url} </div>
fullName={userWithStatus.updated_by?.full_name} </DrawerTrigger>
className='w-5 h-5' {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 <span
className={`${tvMode ? 'text-3xl' : 'text-sm'}`} className={`${tvMode ? 'text-2xl' : 'text-xl'}`}
> >
Updated by {userWithStatus.updated_by?.full_name} {formatTime(userWithStatus.created_at)}
</span> </span>
</div> </div>
)} <div className='flex items-center gap-2 flex-shrink-0 w-full'>
</div> <Calendar
</div> className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
<div className='my-auto'> />
<div className='flex items-center gap-2 text-muted-foreground'> <span
<Clock className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`} /> className={`${tvMode ? 'text-2xl' : 'text-xl'}`}
<span className={`${tvMode ? 'text-3xl' : 'text-xl'}`}> >
{formatTime(userWithStatus.created_at)} {formatDate(userWithStatus.created_at)}
</span> </span>
</div> </div>
<div className='flex items-center gap-2 text-muted-foreground'> <div className='flex items-center gap-2 flex-shrink-0'>
<Calendar {isUpdatedByOther && (
className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`} <div className='flex items-center gap-2'>
/> <BasedAvatar
<span className={`${tvMode ? 'text-3xl' : 'text-xl'}`}> src={userWithStatus.updated_by?.avatar_url}
{formatDate(userWithStatus.created_at)} fullName={userWithStatus.updated_by?.full_name}
</span> 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> </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> </CardContent>
</Card> </Card>
); );
@@ -289,7 +316,7 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
{usersWithStatuses.length === 0 && ( {usersWithStatuses.length === 0 && (
<Card className='p-8 text-center'> <Card className='p-8 text-center'>
<p <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. No status updates have been made in the past day.
</p> </p>
@@ -305,8 +332,8 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
<Input <Input
autoFocus autoFocus
type='text' type='text'
placeholder='Enter status' placeholder='Enter status update...'
className='flex-1 text-base' className='flex-1 text-2xl'
value={statusInput} value={statusInput}
disabled={updateStatusMutation.isPending} disabled={updateStatusMutation.isPending}
onChange={(e) => setStatusInput(e.target.value)} onChange={(e) => setStatusInput(e.target.value)}
@@ -327,9 +354,8 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
className='px-6' className='px-6'
> >
{selectedUsers.length > 0 {selectedUsers.length > 0
? `Update status for ${selectedUsers.length} ? `Update ${selectedUsers.length} ${selectedUsers.length > 1 ? 'users' : 'user'}`
${selectedUsers.length > 1 ? 'users' : 'user'}` : 'Update Status'}
: 'Update status'}
</SubmitButton> </SubmitButton>
</div> </div>
{updateStatusMessage && {updateStatusMessage &&
@@ -347,7 +373,7 @@ export const StatusList = ({ initialStatuses = [] }: ListProps) => {
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button <Button
variant='outline' variant='outline'
className={tvMode ? 'text-3xl p-6' : ''} className={tvMode ? 'text-xl p-6' : ''}
> >
View All Status History View All Status History
</Button> </Button>

View File

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

View File

@@ -139,14 +139,14 @@ export const TechTable = ({ initialStatuses = [] }: TableProps) => {
const thClassName = makeConditionalClassName({ const thClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: 'py-4 px-4 border font-semibold ', defaultClassName: 'py-4 px-4 border font-semibold ',
on: 'lg:text-6xl xl:min-w-[420px]', on: 'lg:text-5xl xl:min-w-[420px]',
off: 'lg:text-5xl xl:min-w-[300px]', off: 'lg:text-4xl xl:min-w-[320px]',
}); });
const tdClassName = makeConditionalClassName({ const tdClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: 'py-2 px-2 border', defaultClassName: 'py-2 px-2 border',
on: 'lg:text-5xl', on: 'lg:text-4xl',
off: 'lg:text-4xl', off: 'lg:text-3xl',
}); });
const tCheckboxClassName = `py-3 px-4 border`; const tCheckboxClassName = `py-3 px-4 border`;
const checkBoxClassName = `lg:scale-200 cursor-pointer`; const checkBoxClassName = `lg:scale-200 cursor-pointer`;
@@ -161,13 +161,13 @@ export const TechTable = ({ initialStatuses = [] }: TableProps) => {
showAsButton={connectionStatus === 'disconnected'} showAsButton={connectionStatus === 'disconnected'}
/> />
{!tvMode && ( {!tvMode && (
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2 text-xs'>
<p>Tired of the old table? </p> <p className='text-muted-foreground'>Tired of the old table? </p>
<Link <Link
href='/status/list' 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> </Link>
</div> </div>
)} )}
@@ -248,7 +248,7 @@ export const TechTable = ({ initialStatuses = [] }: TableProps) => {
fullName={userWithStatus.updated_by?.full_name} fullName={userWithStatus.updated_by?.full_name}
className='w-5 h-5' 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} Updated by {userWithStatus.updated_by.full_name}
</span> </span>
</div> </div>

View File

@@ -1,5 +1,4 @@
export * from './ConnectionStatus'; export * from './ConnectionStatus';
export * from './HistoryDrawer'; export * from './HistoryDrawer';
//export * from './List'; export * from './List';
export * from './StatusList';
export * from './Table'; export * from './Table';

View File

@@ -110,6 +110,10 @@ AZURE_REDIRECT_URI=
AZURE_TENANT_ID= AZURE_TENANT_ID=
AZURE_TENANT_URL= AZURE_TENANT_URL=
# Gib's Auth (Trying to set up Authentik)
#SAML_ENABLED=false
#SAML_PRIVATE_KEY=
############ ############
# Studio - Configuration for the Dashboard # Studio - Configuration for the Dashboard

View File

@@ -5,22 +5,22 @@
# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans # Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
# Reset everything: ./reset.sh # Reset everything: ./reset.sh
name: supabase name: techtracker
networks: networks:
supabase-network: techtracker:
name: supabase-network name: techtracker
driver: bridge driver: bridge
ipam: ipam:
config: config:
- subnet: 172.20.0.0/16 - subnet: 172.19.0.0/16
services: services:
studio: studio:
container_name: supabase-studio container_name: supabase-studio
image: supabase/studio:2025.05.19-sha-3487831 image: supabase/studio:2025.05.19-sha-3487831
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
@@ -61,7 +61,7 @@ services:
kong: kong:
container_name: supabase-kong container_name: supabase-kong
image: kong:2.8.1 image: kong:2.8.1
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
ports: ports:
- ${KONG_HTTP_PORT}:8000/tcp - ${KONG_HTTP_PORT}:8000/tcp
@@ -90,7 +90,7 @@ services:
auth: auth:
container_name: supabase-auth container_name: supabase-auth
image: supabase/gotrue:v2.172.1 image: supabase/gotrue:v2.172.1
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
@@ -173,7 +173,7 @@ services:
GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET} GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET}
GOTRUE_EXTERNAL_AZURE_TENANT_ID: ${AZURE_TENANT_ID} 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} 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 # 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: rest:
container_name: supabase-rest container_name: supabase-rest
image: postgrest/postgrest:v12.2.12 image: postgrest/postgrest:v12.2.12
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -224,7 +224,7 @@ services:
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
container_name: realtime-dev.supabase-realtime container_name: realtime-dev.supabase-realtime
image: supabase/realtime:v2.34.47 image: supabase/realtime:v2.34.47
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -270,7 +270,7 @@ services:
storage: storage:
container_name: supabase-storage container_name: supabase-storage
image: supabase/storage-api:v1.22.17 image: supabase/storage-api:v1.22.17
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./volumes/storage:/var/lib/storage:z - ./volumes/storage:/var/lib/storage:z
@@ -314,7 +314,7 @@ services:
imgproxy: imgproxy:
container_name: supabase-imgproxy container_name: supabase-imgproxy
image: darthsim/imgproxy:v3.8.0 image: darthsim/imgproxy:v3.8.0
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./volumes/storage:/var/lib/storage:z - ./volumes/storage:/var/lib/storage:z
@@ -337,7 +337,7 @@ services:
meta: meta:
container_name: supabase-meta container_name: supabase-meta
image: supabase/postgres-meta:v0.89.0 image: supabase/postgres-meta:v0.89.0
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -356,7 +356,7 @@ services:
functions: functions:
container_name: supabase-edge-functions container_name: supabase-edge-functions
image: supabase/edge-runtime:v1.67.4 image: supabase/edge-runtime:v1.67.4
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./volumes/functions:/home/deno/functions:Z - ./volumes/functions:/home/deno/functions:Z
@@ -381,7 +381,7 @@ services:
analytics: analytics:
container_name: supabase-analytics container_name: supabase-analytics
image: supabase/logflare:1.12.0 image: supabase/logflare:1.12.0
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
ports: ports:
- 4000:4000 - 4000:4000
@@ -430,7 +430,7 @@ services:
db: db:
container_name: supabase-db container_name: supabase-db
image: supabase/postgres:15.8.1.060 image: supabase/postgres:15.8.1.060
networks: [supabase-network] networks: [techtracker]
ports: ports:
- ${POSTGRES_PORT}:${POSTGRES_PORT} - ${POSTGRES_PORT}:${POSTGRES_PORT}
restart: unless-stopped restart: unless-stopped
@@ -490,7 +490,7 @@ services:
vector: vector:
container_name: supabase-vector container_name: supabase-vector
image: timberio/vector:0.28.1-alpine image: timberio/vector:0.28.1-alpine
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
@@ -522,7 +522,7 @@ services:
supavisor: supavisor:
container_name: supabase-pooler container_name: supabase-pooler
image: supabase/supavisor:2.5.1 image: supabase/supavisor:2.5.1
networks: [supabase-network] networks: [techtracker]
restart: unless-stopped restart: unless-stopped
ports: ports:
#- ${POSTGRES_PORT}:5432 #- ${POSTGRES_PORT}:5432
@@ -574,3 +574,4 @@ services:
volumes: volumes:
db-config: db-config:
name: techtracker-db-config

View File

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