feat: add inline file viewer and project logos to award voting
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m24s

- Replace custom download-only file list with full FileViewer component
  that supports inline preview (PDF, video, images, Office docs),
  open in new tab, and download
- Add project logos next to project names in award voting cards
- Backend now returns full file metadata (mimeType, size, pageCount,
  language detection) and project logo URLs
- Award jurors can access files for eligible projects (access control
  already added in prior commit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-14 12:49:28 -04:00
parent 0987d49817
commit 3a6a9a2b45
2 changed files with 38 additions and 68 deletions

View File

@@ -21,10 +21,7 @@ import {
Loader2,
GripVertical,
ChevronDown,
FileText,
Download,
Users,
MapPin,
Tag,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -34,7 +31,8 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Separator } from '@/components/ui/separator'
import { FileViewer } from '@/components/shared/file-viewer'
import { ProjectLogo } from '@/components/shared/project-logo'
export default function JuryAwardVotingPage({
params,
@@ -68,19 +66,6 @@ export default function JuryAwardVotingPage({
})
}
const handleDownload = async (objectKey: string, fileName: string) => {
try {
const result = await utils.file.getDownloadUrl.fetch({
bucket: 'mopc',
objectKey,
fileName,
forDownload: true,
})
if (result.url) window.open(result.url, '_blank')
} catch {
toast.error('Failed to download file')
}
}
// Initialize from existing votes
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
@@ -212,7 +197,7 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)}
onSelect={() => setSelectedProjectId(project.id)}
onToggleExpand={() => toggleExpanded(project.id)}
onDownload={handleDownload}
/>
))}
</div>
@@ -254,7 +239,7 @@ export default function JuryAwardVotingPage({
<span className="font-bold text-lg w-8 text-center text-brand-blue">
{index + 1}
</span>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
<p className="text-sm text-muted-foreground">
@@ -277,7 +262,7 @@ export default function JuryAwardVotingPage({
</Button>
</div>
<CollapsibleContent>
<ProjectDetails project={project} onDownload={handleDownload} />
<ProjectDetails project={project} />
</CollapsibleContent>
</Collapsible>
)
@@ -298,7 +283,7 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)}
onSelect={() => toggleRanked(project.id)}
onToggleExpand={() => toggleExpanded(project.id)}
onDownload={handleDownload}
selectLabel={rankedIds.length < (award.maxRankedPicks || 5) ? `Add to #${rankedIds.length + 1}` : undefined}
/>
))}
@@ -345,12 +330,22 @@ type ProjectData = {
competitionCategory: string | null
country: string | null
tags: string[]
logoKey?: string | null
logoUrl?: string | null
files: Array<{
id: string
fileName: string
fileType: string
mimeType: string
size: number
bucket: string
objectKey: string
version: number
isLate: boolean
pageCount?: number | null
detectedLang?: string | null
langConfidence?: number | null
analyzedAt?: Date | string | null
createdAt: Date
}>
teamMembers: Array<{
@@ -360,19 +355,11 @@ type ProjectData = {
}>
}
function ProjectDetails({
project,
onDownload,
}: {
project: ProjectData
onDownload: (storageKey: string, fileName: string) => void
}) {
function ProjectDetails({ project }: { project: ProjectData }) {
return (
<div className="px-4 pb-4 pt-2 space-y-3 border-t mt-2">
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
{project.description && (
<div>
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
</div>
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
)}
{project.tags.length > 0 && (
@@ -402,32 +389,8 @@ function ProjectDetails({
)}
{project.files.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-1.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
Documents ({project.files.length})
</span>
</div>
<div className="space-y-1">
{project.files.map((file) => (
<button
key={file.id}
onClick={(e) => {
e.stopPropagation()
onDownload(file.objectKey, file.fileName)
}}
className="flex items-center gap-2 w-full text-left rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 transition-colors group"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">{file.fileName}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{file.fileType.replace('_', ' ')}
</Badge>
<Download className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
</div>
<div onClick={(e) => e.stopPropagation()}>
<FileViewer files={project.files as Parameters<typeof FileViewer>[0]['files']} projectId={project.id} />
</div>
)}
@@ -444,7 +407,6 @@ function ProjectCard({
isExpanded,
onSelect,
onToggleExpand,
onDownload,
selectLabel,
}: {
project: ProjectData
@@ -452,7 +414,6 @@ function ProjectCard({
isExpanded: boolean
onSelect: () => void
onToggleExpand: () => void
onDownload: (storageKey: string, fileName: string) => void
selectLabel?: string
}) {
return (
@@ -463,6 +424,7 @@ function ProjectCard({
)}
>
<div className="flex items-start gap-3 p-4">
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" />
<div className="flex-1 min-w-0">
<button
onClick={onToggleExpand}
@@ -488,12 +450,6 @@ function ProjectCard({
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.files.length > 0 && (
<Badge variant="secondary" className="text-xs">
<FileText className="mr-1 h-3 w-3" />
{project.files.length} file{project.files.length !== 1 ? 's' : ''}
</Badge>
)}
</div>
</div>
<Button
@@ -509,7 +465,7 @@ function ProjectCard({
)}
</Button>
</div>
{isExpanded && <ProjectDetails project={project} onDownload={onDownload} />}
{isExpanded && <ProjectDetails project={project} />}
</Card>
)
}

View File

@@ -8,6 +8,7 @@ import { processEligibilityJob } from '../services/award-eligibility-job'
import { resolveAwardWinner } from '../services/award-winner-resolver'
import { getAwardSelectionNotificationTemplate, sendJuryInvitationEmail } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { attachProjectLogoUrls } from '../utils/project-logo-url'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import type { PrismaClient } from '@prisma/client'
@@ -740,14 +741,24 @@ export const specialAwardRouter = router({
competitionCategory: true,
country: true,
tags: true,
logoKey: true,
logoProvider: true,
files: {
where: { replacedById: null },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
bucket: true,
objectKey: true,
version: true,
isLate: true,
pageCount: true,
detectedLang: true,
langConfidence: true,
analyzedAt: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
@@ -768,9 +779,12 @@ export const specialAwardRouter = router({
}),
])
const projectsRaw = eligibleProjects.map((e) => e.project)
const projectsWithLogos = await attachProjectLogoUrls(projectsRaw)
return {
award,
projects: eligibleProjects.map((e) => e.project),
projects: projectsWithLogos,
myVotes,
}
}),