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