Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard

1. Assignment dialog overhaul: replace raw UUID inputs with searchable
   juror Combobox (shows name, email, capacity) and multi-select project
   checklist with bulk assignment support

2. Query invalidation sweep: fix missing invalidations in
   assignment-preview-sheet (roundAssignment.execute) and
   filtering-dashboard (filtering.finalizeResults) so data refreshes
   without page reload

3. Rename Submissions tab to Document Windows with descriptive
   header explaining upload window configuration

4. Connect 6 disconnected settings: storage_provider, local_storage_path,
   avatar_max_size_mb, allowed_image_types, whatsapp_enabled,
   whatsapp_provider - all now accessible in Settings UI

5. Admin dashboard redesign: branded Editorial Command Center with
   Dark Blue gradient header, colored border-l-4 stat cards, staggered
   animations, 2-column layout, action-required panel, activity timeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 16:05:25 +01:00
parent b2279067e2
commit 5965f7889d
7 changed files with 1086 additions and 614 deletions

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,7 @@ import {
FileText, FileText,
Trophy, Trophy,
Clock, Clock,
Upload,
Send, Send,
Download, Download,
Plus, Plus,
@ -78,7 +79,24 @@ import {
ArrowRight, ArrowRight,
RotateCcw, RotateCcw,
X, X,
Check,
ChevronsUpDown,
Search,
} from 'lucide-react' } from 'lucide-react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form' import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table' import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager' import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
@ -696,7 +714,7 @@ export default function RoundDetailPage() {
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []), ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
{ value: 'jury', label: 'Jury', icon: Users }, { value: 'jury', label: 'Jury', icon: Users },
{ value: 'config', label: 'Config', icon: Settings }, { value: 'config', label: 'Config', icon: Settings },
{ value: 'windows', label: 'Submissions', icon: Clock }, { value: 'windows', label: 'Document Windows', icon: Upload },
{ value: 'awards', label: 'Awards', icon: Trophy }, { value: 'awards', label: 'Awards', icon: Trophy },
].map((tab) => ( ].map((tab) => (
<TabsTrigger <TabsTrigger
@ -1476,7 +1494,7 @@ export default function RoundDetailPage() {
</div> </div>
{/* Individual Assignments Table */} {/* Individual Assignments Table */}
<IndividualAssignmentsTable roundId={roundId} /> <IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
{/* Unassigned Queue */} {/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} /> <RoundUnassignedQueue roundId={roundId} />
@ -1635,6 +1653,12 @@ export default function RoundDetailPage() {
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */} {/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
<TabsContent value="windows" className="space-y-4"> <TabsContent value="windows" className="space-y-4">
<div className="space-y-1 mb-4">
<h3 className="text-lg font-semibold">Document Upload Windows</h3>
<p className="text-sm text-muted-foreground">
Configure when applicants can upload documents for each phase of this round. These windows control the submission periods independently of the round&apos;s active status.
</p>
</div>
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} /> <SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent> </TabsContent>
@ -1969,10 +1993,18 @@ function ExportEvaluationsDialog({
// ── Individual Assignments Table ───────────────────────────────────────── // ── Individual Assignments Table ─────────────────────────────────────────
function IndividualAssignmentsTable({ roundId }: { roundId: string }) { function IndividualAssignmentsTable({
roundId,
projectStates,
}: {
roundId: string
projectStates: any[] | undefined
}) {
const [addDialogOpen, setAddDialogOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false)
const [newUserId, setNewUserId] = useState('') const [selectedJurorId, setSelectedJurorId] = useState('')
const [newProjectId, setNewProjectId] = useState('') const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery( const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
@ -1980,9 +2012,15 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
{ refetchInterval: 15_000 }, { refetchInterval: 15_000 },
) )
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: addDialogOpen },
)
const deleteMutation = trpc.assignment.delete.useMutation({ const deleteMutation = trpc.assignment.delete.useMutation({
onSuccess: () => { onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId }) utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success('Assignment removed') toast.success('Assignment removed')
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
@ -1991,14 +2029,102 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
const createMutation = trpc.assignment.create.useMutation({ const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => { onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId }) utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success('Assignment created') toast.success('Assignment created')
setAddDialogOpen(false) resetDialog()
setNewUserId('')
setNewProjectId('')
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
onSuccess: (result) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success(`${result.created} assignment(s) created`)
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const resetDialog = useCallback(() => {
setAddDialogOpen(false)
setSelectedJurorId('')
setSelectedProjectIds(new Set())
setProjectSearch('')
}, [])
const selectedJuror = useMemo(
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
[juryMembers, selectedJurorId],
)
// Filter projects by search term
const filteredProjects = useMemo(() => {
const items = projectStates ?? []
if (!projectSearch) return items
const q = projectSearch.toLowerCase()
return items.filter((ps: any) =>
ps.project?.title?.toLowerCase().includes(q) ||
ps.project?.teamName?.toLowerCase().includes(q) ||
ps.project?.competitionCategory?.toLowerCase().includes(q)
)
}, [projectStates, projectSearch])
// Existing assignments for the selected juror (to grey out already-assigned projects)
const jurorExistingProjectIds = useMemo(() => {
if (!selectedJurorId || !assignments) return new Set<string>()
return new Set(
assignments
.filter((a: any) => a.userId === selectedJurorId)
.map((a: any) => a.projectId)
)
}, [selectedJurorId, assignments])
const toggleProject = useCallback((projectId: string) => {
setSelectedProjectIds(prev => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
}, [])
const selectAllUnassigned = useCallback(() => {
const unassigned = filteredProjects
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
.map((ps: any) => ps.project?.id)
.filter(Boolean)
setSelectedProjectIds(new Set(unassigned))
}, [filteredProjects, jurorExistingProjectIds])
const handleCreate = useCallback(() => {
if (!selectedJurorId || selectedProjectIds.size === 0) return
const projectIds = Array.from(selectedProjectIds)
if (projectIds.length === 1) {
createMutation.mutate({
userId: selectedJurorId,
projectId: projectIds[0],
roundId,
})
} else {
bulkCreateMutation.mutate({
roundId,
assignments: projectIds.map(projectId => ({
userId: selectedJurorId,
projectId,
})),
})
}
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -2071,44 +2197,220 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
</CardContent> </CardContent>
{/* Add Assignment Dialog */} {/* Add Assignment Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}> <Dialog open={addDialogOpen} onOpenChange={(open) => {
<DialogContent> if (!open) resetDialog()
else setAddDialogOpen(true)
}}>
<DialogContent className="sm:max-w-[540px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Assignment</DialogTitle> <DialogTitle>Add Assignment</DialogTitle>
<DialogDescription> <DialogDescription>
Manually assign a juror to evaluate a project Select a juror and one or more projects to assign
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Juror Selector */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Juror User ID</Label> <Label className="text-sm font-medium">Juror</Label>
<Input <Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
placeholder="Enter jury member user ID..." <PopoverTrigger asChild>
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Project ID</Label>
<Input
placeholder="Enter project ID..."
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
<Button <Button
onClick={() => createMutation.mutate({ variant="outline"
userId: newUserId, role="combobox"
projectId: newProjectId, aria-expanded={jurorPopoverOpen}
roundId, className="w-full justify-between font-normal"
})}
disabled={!newUserId || !newProjectId || createMutation.isPending}
> >
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />} {selectedJuror
Create Assignment ? (
<span className="flex items-center gap-2 truncate">
<span className="truncate">{selectedJuror.name || selectedJuror.email}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">
{selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
</Badge>
</span>
)
: <span className="text-muted-foreground">Select a jury member...</span>
}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-0" align="start">
<Command>
<CommandInput placeholder="Search by name or email..." />
<CommandList>
<CommandEmpty>No jury members found.</CommandEmpty>
<CommandGroup>
{juryMembers?.map((juror: any) => {
const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
return (
<CommandItem
key={juror.id}
value={`${juror.name ?? ''} ${juror.email}`}
disabled={atCapacity}
onSelect={() => {
setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
setSelectedProjectIds(new Set())
setJurorPopoverOpen(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedJurorId === juror.id ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{juror.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{juror.email}
</p>
</div>
<Badge
variant={atCapacity ? 'destructive' : 'secondary'}
className="text-[10px] ml-2 shrink-0"
>
{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
{atCapacity ? ' full' : ''}
</Badge>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Project Multi-Select */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Projects
{selectedProjectIds.size > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">
({selectedProjectIds.size} selected)
</span>
)}
</Label>
{selectedJurorId && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={selectAllUnassigned}
>
Select all
</Button>
{selectedProjectIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedProjectIds(new Set())}
>
Clear
</Button>
)}
</div>
)}
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* Project checklist */}
<ScrollArea className="h-[240px] rounded-md border">
<div className="p-2 space-y-0.5">
{!selectedJurorId ? (
<p className="text-sm text-muted-foreground text-center py-8">
Select a juror first
</p>
) : filteredProjects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No projects found
</p>
) : (
filteredProjects.map((ps: any) => {
const project = ps.project
if (!project) return null
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
const isSelected = selectedProjectIds.has(project.id)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50',
)}
>
<Checkbox
checked={isSelected}
disabled={alreadyAssigned}
onCheckedChange={() => toggleProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="truncate">{project.title}</span>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px]">
{project.competitionCategory === 'STARTUP'
? 'Startup'
: project.competitionCategory === 'BUSINESS_CONCEPT'
? 'Concept'
: project.competitionCategory}
</Badge>
)}
{alreadyAssigned && (
<Badge variant="secondary" className="text-[10px]">
Assigned
</Badge>
)}
</div>
</div>
</label>
)
})
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetDialog}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
>
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedProjectIds.size <= 1
? 'Create Assignment'
: `Create ${selectedProjectIds.size} Assignments`
}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content' import { SettingsContent } from '@/components/settings/settings-content'
// Categories that only super admins can access // Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY']) const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) { async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({ const settings = await prisma.systemSettings.findMany({

View File

@ -45,6 +45,8 @@ export function AssignmentPreviewSheet({
toast.success(`Created ${result.created} assignments`) toast.success(`Created ${result.created} assignments`)
utils.roundAssignment.coverageReport.invalidate({ roundId }) utils.roundAssignment.coverageReport.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId }) utils.roundAssignment.unassignedQueue.invalidate({ roundId })
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
onOpenChange(false) onOpenChange(false)
}, },
onError: (err) => { onError: (err) => {

View File

@ -198,6 +198,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
onSuccess: (data) => { onSuccess: (data) => {
utils.filtering.getResults.invalidate() utils.filtering.getResults.invalidate()
utils.filtering.getResultStats.invalidate({ roundId }) utils.filtering.getResultStats.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.project.list.invalidate()
toast.success( toast.success(
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` + `Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '') (data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')

View File

@ -25,6 +25,7 @@ import {
ShieldAlert, ShieldAlert,
Globe, Globe,
Webhook, Webhook,
MessageCircle,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
@ -103,8 +104,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
]) ])
const storageSettings = getSettingsByKeys([ const storageSettings = getSettingsByKeys([
'storage_provider',
'local_storage_path',
'max_file_size_mb', 'max_file_size_mb',
'avatar_max_size_mb',
'allowed_file_types', 'allowed_file_types',
'allowed_image_types',
]) ])
const securitySettings = getSettingsByKeys([ const securitySettings = getSettingsByKeys([
@ -147,6 +152,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'anomaly_off_hours_end', 'anomaly_off_hours_end',
]) ])
const whatsappSettings = getSettingsByKeys([
'whatsapp_enabled',
'whatsapp_provider',
])
const localizationSettings = getSettingsByKeys([ const localizationSettings = getSettingsByKeys([
'localization_enabled_locales', 'localization_enabled_locales',
'localization_default_locale', 'localization_default_locale',
@ -183,6 +193,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Newspaper className="h-4 w-4" /> <Newspaper className="h-4 w-4" />
Digest Digest
</TabsTrigger> </TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="whatsapp" className="gap-2 shrink-0">
<MessageCircle className="h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
{isSuperAdmin && ( {isSuperAdmin && (
<TabsTrigger value="security" className="gap-2 shrink-0"> <TabsTrigger value="security" className="gap-2 shrink-0">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
@ -259,6 +275,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Newspaper className="h-4 w-4" /> <Newspaper className="h-4 w-4" />
Digest Digest
</TabsTrigger> </TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="whatsapp" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<MessageCircle className="h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
</TabsList> </TabsList>
</div> </div>
<div> <div>
@ -502,6 +524,24 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</Card> </Card>
</AnimatedCard> </AnimatedCard>
</TabsContent> </TabsContent>
{isSuperAdmin && (
<TabsContent value="whatsapp" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>WhatsApp Notifications</CardTitle>
<CardDescription>
Configure WhatsApp messaging for notifications
</CardDescription>
</CardHeader>
<CardContent>
<WhatsAppSettingsSection settings={whatsappSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
</div>{/* end content area */} </div>{/* end content area */}
</div>{/* end lg:flex */} </div>{/* end lg:flex */}
</Tabs> </Tabs>
@ -794,6 +834,29 @@ function AuditSettingsSection({ settings }: { settings: Record<string, string> }
) )
} }
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
return (
<div className="space-y-4">
<SettingToggle
label="Enable WhatsApp Notifications"
description="Send notifications via WhatsApp in addition to email"
settingKey="whatsapp_enabled"
value={settings.whatsapp_enabled || 'false'}
/>
<SettingSelect
label="WhatsApp Provider"
description="Select the API provider for sending WhatsApp messages"
settingKey="whatsapp_provider"
value={settings.whatsapp_provider || 'META'}
options={[
{ value: 'META', label: 'Meta (WhatsApp Business API)' },
{ value: 'TWILIO', label: 'Twilio' },
]}
/>
</div>
)
}
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) { function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
const mutation = useSettingsMutation() const mutation = useSettingsMutation()
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',') const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')

View File

@ -22,6 +22,14 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
// Note: Storage provider cache is cleared server-side when settings are updated // Note: Storage provider cache is cleared server-side when settings are updated
const COMMON_IMAGE_TYPES = [
{ value: 'image/png', label: 'PNG (.png)' },
{ value: 'image/jpeg', label: 'JPEG (.jpg, .jpeg)' },
{ value: 'image/webp', label: 'WebP (.webp)' },
{ value: 'image/gif', label: 'GIF (.gif)' },
{ value: 'image/svg+xml', label: 'SVG (.svg)' },
]
const COMMON_FILE_TYPES = [ const COMMON_FILE_TYPES = [
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' }, { value: 'application/pdf', label: 'PDF Documents (.pdf)' },
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' }, { value: 'video/mp4', label: 'MP4 Video (.mp4)' },
@ -41,6 +49,7 @@ const formSchema = z.object({
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'), max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'), avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'), allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
allowed_image_types: z.array(z.string()).min(1, 'Select at least one image type'),
}) })
type FormValues = z.infer<typeof formSchema> type FormValues = z.infer<typeof formSchema>
@ -52,6 +61,7 @@ interface StorageSettingsFormProps {
max_file_size_mb?: string max_file_size_mb?: string
avatar_max_size_mb?: string avatar_max_size_mb?: string
allowed_file_types?: string allowed_file_types?: string
allowed_image_types?: string
} }
} }
@ -68,6 +78,16 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg'] allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
} }
// Parse allowed image types from JSON string
let allowedImageTypes: string[] = []
try {
allowedImageTypes = settings.allowed_image_types
? JSON.parse(settings.allowed_image_types)
: ['image/png', 'image/jpeg', 'image/webp']
} catch {
allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp']
}
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@ -76,6 +96,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
max_file_size_mb: settings.max_file_size_mb || '500', max_file_size_mb: settings.max_file_size_mb || '500',
avatar_max_size_mb: settings.avatar_max_size_mb || '5', avatar_max_size_mb: settings.avatar_max_size_mb || '5',
allowed_file_types: allowedTypes, allowed_file_types: allowedTypes,
allowed_image_types: allowedImageTypes,
}, },
}) })
@ -99,6 +120,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
{ key: 'max_file_size_mb', value: data.max_file_size_mb }, { key: 'max_file_size_mb', value: data.max_file_size_mb },
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb }, { key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) }, { key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
{ key: 'allowed_image_types', value: JSON.stringify(data.allowed_image_types) },
], ],
}) })
} }
@ -255,6 +277,57 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
)} )}
/> />
<FormField
control={form.control}
name="allowed_image_types"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel>Allowed Image Types (Avatars/Logos)</FormLabel>
<FormDescription>
Select which image formats can be used for profile pictures and project logos
</FormDescription>
</div>
<div className="grid gap-3 md:grid-cols-2">
{COMMON_IMAGE_TYPES.map((type) => (
<FormField
key={type.value}
control={form.control}
name="allowed_image_types"
render={({ field }) => {
return (
<FormItem
key={type.value}
className="flex items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(type.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, type.value])
: field.onChange(
field.value?.filter(
(value) => value !== type.value
)
)
}}
/>
</FormControl>
<FormLabel className="cursor-pointer text-sm font-normal">
{type.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{storageProvider === 's3' && ( {storageProvider === 's3' && (
<div className="rounded-lg border border-muted bg-muted/50 p-4"> <div className="rounded-lg border border-muted bg-muted/50 p-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">