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
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:
@@ -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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user