Files
MOPC-Portal/src/components/shared/project-logo-upload.tsx
Matt cfee3bc8a9
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
feat: round finalization with ranking-based outcomes + award pool notifications
- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:14:41 +01:00

323 lines
9.4 KiB
TypeScript

'use client'
import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import type { Area } from 'react-easy-crop'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Upload, Loader2, Trash2, ZoomIn, ImageIcon } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type ProjectLogoUploadProps = {
projectId: string
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = new Image()
image.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = reject
image.src = imageSrc
})
const canvas = document.createElement('canvas')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error('Canvas toBlob failed'))
},
'image/png',
0.9
)
})
}
export function ProjectLogoUpload({
projectId,
currentLogoUrl,
onUploadComplete,
children,
}: ProjectLogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getProjectLogoUploadUrl.useMutation()
const confirmUpload = trpc.applicant.confirmProjectLogo.useMutation()
const deleteLogo = trpc.applicant.deleteProjectLogo.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
const reader = new FileReader()
reader.onload = (ev) => {
setImageSrc(ev.target?.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!imageSrc || !croppedAreaPixels) return
setIsUploading(true)
try {
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
projectId,
fileName: 'logo.png',
contentType: 'image/png',
})
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: croppedBlob,
headers: { 'Content-Type': 'image/png' },
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
await confirmUpload.mutateAsync({ projectId, key, providerType })
toast.success('Project logo updated')
setOpen(false)
resetState()
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload logo. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleCancel = () => {
resetState()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={([val]) => setZoom(val)}
className="flex-1"
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
resetState()
fileInputRef.current?.click()
}}
className="w-full"
>
Choose a different image
</Button>
</>
) : (
<>
{/* Current logo preview */}
{currentLogoUrl && (
<div className="flex justify-center">
<img
src={currentLogoUrl}
alt="Current logo"
className="h-24 w-24 rounded-xl object-cover border"
/>
</div>
)}
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
{imageSrc && (
<Button
onClick={handleUpload}
disabled={!croppedAreaPixels || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}