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, Loader2,
GripVertical, GripVertical,
ChevronDown, ChevronDown,
FileText,
Download,
Users, Users,
MapPin,
Tag, Tag,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -34,7 +31,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } 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({ export default function JuryAwardVotingPage({
params, 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 // Initialize from existing votes
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) { if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
@@ -212,7 +197,7 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)} isExpanded={expandedProjects.has(project.id)}
onSelect={() => setSelectedProjectId(project.id)} onSelect={() => setSelectedProjectId(project.id)}
onToggleExpand={() => toggleExpanded(project.id)} onToggleExpand={() => toggleExpanded(project.id)}
onDownload={handleDownload}
/> />
))} ))}
</div> </div>
@@ -254,7 +239,7 @@ export default function JuryAwardVotingPage({
<span className="font-bold text-lg w-8 text-center text-brand-blue"> <span className="font-bold text-lg w-8 text-center text-brand-blue">
{index + 1} {index + 1}
</span> </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"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p> <p className="font-medium truncate">{project.title}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -277,7 +262,7 @@ export default function JuryAwardVotingPage({
</Button> </Button>
</div> </div>
<CollapsibleContent> <CollapsibleContent>
<ProjectDetails project={project} onDownload={handleDownload} /> <ProjectDetails project={project} />
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
) )
@@ -298,7 +283,7 @@ export default function JuryAwardVotingPage({
isExpanded={expandedProjects.has(project.id)} isExpanded={expandedProjects.has(project.id)}
onSelect={() => toggleRanked(project.id)} onSelect={() => toggleRanked(project.id)}
onToggleExpand={() => toggleExpanded(project.id)} onToggleExpand={() => toggleExpanded(project.id)}
onDownload={handleDownload}
selectLabel={rankedIds.length < (award.maxRankedPicks || 5) ? `Add to #${rankedIds.length + 1}` : undefined} selectLabel={rankedIds.length < (award.maxRankedPicks || 5) ? `Add to #${rankedIds.length + 1}` : undefined}
/> />
))} ))}
@@ -345,12 +330,22 @@ type ProjectData = {
competitionCategory: string | null competitionCategory: string | null
country: string | null country: string | null
tags: string[] tags: string[]
logoKey?: string | null
logoUrl?: string | null
files: Array<{ files: Array<{
id: string id: string
fileName: string fileName: string
fileType: string fileType: string
mimeType: string
size: number
bucket: string bucket: string
objectKey: string objectKey: string
version: number
isLate: boolean
pageCount?: number | null
detectedLang?: string | null
langConfidence?: number | null
analyzedAt?: Date | string | null
createdAt: Date createdAt: Date
}> }>
teamMembers: Array<{ teamMembers: Array<{
@@ -360,19 +355,11 @@ type ProjectData = {
}> }>
} }
function ProjectDetails({ function ProjectDetails({ project }: { project: ProjectData }) {
project,
onDownload,
}: {
project: ProjectData
onDownload: (storageKey: string, fileName: string) => void
}) {
return ( 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 && ( {project.description && (
<div>
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p> <p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
</div>
)} )}
{project.tags.length > 0 && ( {project.tags.length > 0 && (
@@ -402,32 +389,8 @@ function ProjectDetails({
)} )}
{project.files.length > 0 && ( {project.files.length > 0 && (
<div> <div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 mb-1.5"> <FileViewer files={project.files as Parameters<typeof FileViewer>[0]['files']} projectId={project.id} />
<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> </div>
)} )}
@@ -444,7 +407,6 @@ function ProjectCard({
isExpanded, isExpanded,
onSelect, onSelect,
onToggleExpand, onToggleExpand,
onDownload,
selectLabel, selectLabel,
}: { }: {
project: ProjectData project: ProjectData
@@ -452,7 +414,6 @@ function ProjectCard({
isExpanded: boolean isExpanded: boolean
onSelect: () => void onSelect: () => void
onToggleExpand: () => void onToggleExpand: () => void
onDownload: (storageKey: string, fileName: string) => void
selectLabel?: string selectLabel?: string
}) { }) {
return ( return (
@@ -463,6 +424,7 @@ function ProjectCard({
)} )}
> >
<div className="flex items-start gap-3 p-4"> <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"> <div className="flex-1 min-w-0">
<button <button
onClick={onToggleExpand} onClick={onToggleExpand}
@@ -488,12 +450,6 @@ function ProjectCard({
{project.competitionCategory.replace('_', ' ')} {project.competitionCategory.replace('_', ' ')}
</Badge> </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>
</div> </div>
<Button <Button
@@ -509,7 +465,7 @@ function ProjectCard({
)} )}
</Button> </Button>
</div> </div>
{isExpanded && <ProjectDetails project={project} onDownload={onDownload} />} {isExpanded && <ProjectDetails project={project} />}
</Card> </Card>
) )
} }

View File

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