repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
certificates
stars
latest
clone command
git clone gitlawb://did:key:z6Mkqhmm...XL9c/certificatesgit clone gitlawb://did:key:z6Mkqhmm.../certificates019974a8sync from playground15h ago| #1 | import { useState, useRef, useCallback } from "react"; |
| #2 | import { collection, doc, setDoc } from "firebase/firestore"; |
| #3 | import { db } from "../firebase"; |
| #4 | import { useAuth } from "../contexts/AuthContext"; |
| #5 | import { useToast } from "../components/Toast"; |
| #6 | import { nanoid } from "nanoid"; |
| #7 | import { Upload, X, CheckCircle, AlertCircle, FileImage, Plus } from "lucide-react"; |
| #8 | import * as pdfjsLib from "pdfjs-dist"; |
| #9 | import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; |
| #10 | import type { Certificate } from "../types"; |
| #11 | |
| #12 | // Use Vite's ?url import to get the correct worker path |
| #13 | pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl; |
| #14 | |
| #15 | const ACCEPTED_TYPES = [ |
| #16 | "image/png", |
| #17 | "image/jpeg", |
| #18 | "image/svg+xml", |
| #19 | "application/pdf", |
| #20 | ]; |
| #21 | |
| #22 | const ACCEPTED_EXTENSIONS = ".png,.jpg,.jpeg,.svg,.pdf"; |
| #23 | const MAX_FILE_SIZE_MB = 10; |
| #24 | const MAX_FILES = 20; |
| #25 | // Firestore document limit is ~1 MiB (~1,048,576 bytes). JSON encoding + other fields add overhead. |
| #26 | // Cap data URL to 600 KB to stay safely under the limit. |
| #27 | const MAX_DATAURL_BYTES = 600_000; |
| #28 | |
| #29 | /** |
| #30 | * Process a file into a compressed base64 JPEG data URL. |
| #31 | * - Raster images (PNG/JPG): draw to canvas, compress as JPEG. |
| #32 | * - SVG: rasterize via Image + canvas, compress as JPEG. |
| #33 | * - PDF: render first page to canvas via pdf.js, compress as JPEG. |
| #34 | */ |
| #35 | async function processFile(file: File): Promise<{ dataUrl: string }> { |
| #36 | if (file.type === "application/pdf") { |
| #37 | return processPdf(file); |
| #38 | } |
| #39 | return processImage(file); |
| #40 | } |
| #41 | |
| #42 | async function processImage(file: File): Promise<{ dataUrl: string }> { |
| #43 | return new Promise((resolve, reject) => { |
| #44 | const reader = new FileReader(); |
| #45 | reader.onload = () => { |
| #46 | const img = new Image(); |
| #47 | img.onload = () => { |
| #48 | const canvas = document.createElement("canvas"); |
| #49 | let { width, height } = img; |
| #50 | const maxWidth = 1000; |
| #51 | if (width > maxWidth) { |
| #52 | height = Math.round((height * maxWidth) / width); |
| #53 | width = maxWidth; |
| #54 | } |
| #55 | canvas.width = width; |
| #56 | canvas.height = height; |
| #57 | const ctx = canvas.getContext("2d")!; |
| #58 | ctx.drawImage(img, 0, 0, width, height); |
| #59 | const dataUrl = canvas.toDataURL("image/jpeg", 0.6); |
| #60 | resolve({ dataUrl }); |
| #61 | }; |
| #62 | img.onerror = () => reject(new Error("Failed to decode image")); |
| #63 | img.src = reader.result as string; |
| #64 | }; |
| #65 | reader.onerror = () => reject(new Error("Failed to read file")); |
| #66 | reader.readAsDataURL(file); |
| #67 | }); |
| #68 | } |
| #69 | |
| #70 | async function processPdf(file: File): Promise<{ dataUrl: string }> { |
| #71 | const arrayBuffer = await file.arrayBuffer(); |
| #72 | const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; |
| #73 | const page = await pdf.getPage(1); |
| #74 | |
| #75 | // Render at a scale that fits within 1000px wide |
| #76 | const viewport = page.getViewport({ scale: 1 }); |
| #77 | const scale = Math.min(1000 / viewport.width, 1.5); |
| #78 | const scaledViewport = page.getViewport({ scale }); |
| #79 | |
| #80 | const canvas = document.createElement("canvas"); |
| #81 | canvas.width = scaledViewport.width; |
| #82 | canvas.height = scaledViewport.height; |
| #83 | const ctx = canvas.getContext("2d")!; |
| #84 | |
| #85 | await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise; |
| #86 | |
| #87 | const dataUrl = canvas.toDataURL("image/jpeg", 0.6); |
| #88 | return { dataUrl }; |
| #89 | } |
| #90 | |
| #91 | interface UploadedFile { |
| #92 | id: string; |
| #93 | file: File; |
| #94 | preview: string; |
| #95 | recipientName: string; |
| #96 | courseTitle: string; |
| #97 | organization: string; |
| #98 | issueDate: string; |
| #99 | status: "pending" | "uploading" | "done" | "error"; |
| #100 | error?: string; |
| #101 | } |
| #102 | |
| #103 | export default function UploadCert() { |
| #104 | const { user } = useAuth(); |
| #105 | const { addToast } = useToast(); |
| #106 | const fileRef = useRef<HTMLInputElement>(null); |
| #107 | const [items, setItems] = useState<UploadedFile[]>([]); |
| #108 | const [uploading, setUploading] = useState(false); |
| #109 | const [result, setResult] = useState<{ success: number; errors: number; errorDetails: string[] } | null>(null); |
| #110 | const [dragOver, setDragOver] = useState(false); |
| #111 | |
| #112 | const handleFiles = useCallback( |
| #113 | (files: FileList | File[]) => { |
| #114 | const arr = Array.from(files); |
| #115 | const valid = arr.filter((f) => { |
| #116 | if (!ACCEPTED_TYPES.includes(f.type)) { |
| #117 | addToast(`${f.name}: unsupported format. Use PNG, JPG, SVG, or PDF.`, "error"); |
| #118 | return false; |
| #119 | } |
| #120 | if (f.size > MAX_FILE_SIZE_MB * 1024 * 1024) { |
| #121 | addToast(`${f.name}: exceeds ${MAX_FILE_SIZE_MB}MB limit.`, "error"); |
| #122 | return false; |
| #123 | } |
| #124 | return true; |
| #125 | }); |
| #126 | |
| #127 | if (items.length + valid.length > MAX_FILES) { |
| #128 | addToast(`Maximum ${MAX_FILES} files allowed. Remove some first.`, "error"); |
| #129 | return; |
| #130 | } |
| #131 | |
| #132 | const newItems: UploadedFile[] = valid.map((file) => ({ |
| #133 | id: nanoid(8), |
| #134 | file, |
| #135 | preview: URL.createObjectURL(file), |
| #136 | recipientName: "", |
| #137 | courseTitle: "", |
| #138 | organization: "", |
| #139 | issueDate: new Date().toISOString().split("T")[0], |
| #140 | status: "pending", |
| #141 | })); |
| #142 | |
| #143 | setItems((prev) => [...prev, ...newItems]); |
| #144 | }, |
| #145 | [items.length, addToast] |
| #146 | ); |
| #147 | |
| #148 | const handleDrop = (e: React.DragEvent) => { |
| #149 | e.preventDefault(); |
| #150 | setDragOver(false); |
| #151 | if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files); |
| #152 | }; |
| #153 | |
| #154 | const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| #155 | if (e.target.files && e.target.files.length > 0) { |
| #156 | handleFiles(e.target.files); |
| #157 | e.target.value = ""; |
| #158 | } |
| #159 | }; |
| #160 | |
| #161 | const updateItem = (id: string, field: keyof UploadedFile, value: string) => { |
| #162 | setItems((prev) => |
| #163 | prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)) |
| #164 | ); |
| #165 | }; |
| #166 | |
| #167 | const removeItem = (id: string) => { |
| #168 | setItems((prev) => { |
| #169 | const item = prev.find((i) => i.id === id); |
| #170 | if (item) URL.revokeObjectURL(item.preview); |
| #171 | return prev.filter((i) => i.id !== id); |
| #172 | }); |
| #173 | }; |
| #174 | |
| #175 | const handleUpload = async () => { |
| #176 | const incomplete = items.filter((i) => !i.recipientName || !i.courseTitle); |
| #177 | if (incomplete.length > 0) { |
| #178 | addToast("Please fill in Recipient Name and Course Title for all certificates.", "error"); |
| #179 | return; |
| #180 | } |
| #181 | |
| #182 | setUploading(true); |
| #183 | let success = 0; |
| #184 | let errors = 0; |
| #185 | const errorDetails: string[] = []; |
| #186 | |
| #187 | for (const item of items) { |
| #188 | setItems((prev) => |
| #189 | prev.map((i) => (i.id === item.id ? { ...i, status: "uploading" } : i)) |
| #190 | ); |
| #191 | try { |
| #192 | console.log(`[UploadCert] Processing file: ${item.file.name} (${item.file.type}, ${Math.round(item.file.size / 1024)} KB)`); |
| #193 | const { dataUrl } = await processFile(item.file); |
| #194 | console.log(`[UploadCert] Processed data URL length: ${dataUrl.length} chars (${Math.round(dataUrl.length / 1024)} KB)`); |
| #195 | |
| #196 | // Pre-flight size check — Firestore limit is ~1 MiB per document |
| #197 | if (dataUrl.length > MAX_DATAURL_BYTES) { |
| #198 | throw new Error( |
| #199 | `Processed image is too large (${Math.round(dataUrl.length / 1024)} KB). ` + |
| #200 | `Try a smaller or lower-resolution file.` |
| #201 | ); |
| #202 | } |
| #203 | |
| #204 | const certId = nanoid(10); |
| #205 | const cert: Certificate = { |
| #206 | id: certId, |
| #207 | recipientName: item.recipientName.slice(0, 200), |
| #208 | recipientEmail: "", |
| #209 | courseTitle: item.courseTitle.slice(0, 500), |
| #210 | organization: item.organization.slice(0, 300) || "Unknown", |
| #211 | issueDate: item.issueDate, |
| #212 | expiryDate: null, |
| #213 | // Use "1" as templateId since the deployed Firestore rules may not have "upload". |
| #214 | // certType: "uploaded" is what distinguishes these from generated certs. |
| #215 | templateId: "1", |
| #216 | customText: "", |
| #217 | status: "active", |
| #218 | createdAt: new Date().toISOString(), |
| #219 | issuedBy: user?.uid || "unknown", |
| #220 | certType: "uploaded", |
| #221 | fileData: dataUrl, |
| #222 | fileType: "image/jpeg", |
| #223 | fileName: item.file.name, |
| #224 | }; |
| #225 | |
| #226 | console.log(`[UploadCert] Writing to Firestore, doc ID: ${certId}, total doc size estimate: ${Math.round(JSON.stringify(cert).length / 1024)} KB`); |
| #227 | await setDoc(doc(db, "certificates", certId), cert); |
| #228 | console.log(`[UploadCert] Success: ${item.file.name}`); |
| #229 | setItems((prev) => |
| #230 | prev.map((i) => (i.id === item.id ? { ...i, status: "done" } : i)) |
| #231 | ); |
| #232 | success++; |
| #233 | } catch (err: unknown) { |
| #234 | const msg = err instanceof Error ? err.message : String(err); |
| #235 | console.error(`[UploadCert] Failed: ${item.file.name}`, err); |
| #236 | const detail = `${item.file.name}: ${msg}`; |
| #237 | errorDetails.push(detail); |
| #238 | setItems((prev) => |
| #239 | prev.map((i) => |
| #240 | i.id === item.id ? { ...i, status: "error", error: msg } : i |
| #241 | ) |
| #242 | ); |
| #243 | errors++; |
| #244 | } |
| #245 | } |
| #246 | |
| #247 | setResult({ success, errors, errorDetails }); |
| #248 | setUploading(false); |
| #249 | |
| #250 | if (errors === 0) { |
| #251 | addToast(`All ${success} certificates uploaded!`, "success"); |
| #252 | } else { |
| #253 | addToast(`${success} uploaded, ${errors} failed.`, "error"); |
| #254 | } |
| #255 | }; |
| #256 | |
| #257 | const resetAll = () => { |
| #258 | items.forEach((i) => URL.revokeObjectURL(i.preview)); |
| #259 | setItems([]); |
| #260 | setResult(null); |
| #261 | }; |
| #262 | |
| #263 | if (result) { |
| #264 | return ( |
| #265 | <div className="max-w-lg mx-auto mt-8"> |
| #266 | <div className="bg-white rounded-2xl border border-gray-200 p-8 text-center"> |
| #267 | <div |
| #268 | className={`w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4 ${ |
| #269 | result.errors === 0 ? "bg-emerald-100" : "bg-yellow-100" |
| #270 | }`} |
| #271 | > |
| #272 | {result.errors === 0 ? ( |
| #273 | <CheckCircle className="w-7 h-7 text-emerald-600" /> |
| #274 | ) : ( |
| #275 | <AlertCircle className="w-7 h-7 text-yellow-600" /> |
| #276 | )} |
| #277 | </div> |
| #278 | <h2 className="text-xl font-bold text-gray-900 mb-2">Upload Complete</h2> |
| #279 | <p className="text-sm text-gray-500 mb-4"> |
| #280 | <span className="text-emerald-600 font-bold">{result.success}</span> certificates |
| #281 | hosted |
| #282 | {result.errors > 0 && ( |
| #283 | <> |
| #284 | {" "}·{" "} |
| #285 | <span className="text-red-600 font-bold">{result.errors}</span> errors |
| #286 | </> |
| #287 | )} |
| #288 | </p> |
| #289 | {result.errorDetails.length > 0 && ( |
| #290 | <div className="mb-4 text-left bg-red-50 border border-red-200 rounded-lg p-4"> |
| #291 | <p className="text-xs font-semibold text-red-700 mb-2">Error Details:</p> |
| #292 | <ul className="space-y-1"> |
| #293 | {result.errorDetails.map((detail, i) => ( |
| #294 | <li key={i} className="text-xs text-red-600 font-mono break-all"> |
| #295 | {detail} |
| #296 | </li> |
| #297 | ))} |
| #298 | </ul> |
| #299 | </div> |
| #300 | )} |
| #301 | <button |
| #302 | onClick={resetAll} |
| #303 | className="px-5 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium" |
| #304 | > |
| #305 | Upload More |
| #306 | </button> |
| #307 | </div> |
| #308 | </div> |
| #309 | ); |
| #310 | } |
| #311 | |
| #312 | return ( |
| #313 | <div> |
| #314 | <h1 className="text-2xl font-bold text-gray-900 mb-2">Upload Certificates</h1> |
| #315 | <p className="text-sm text-gray-500 mb-6"> |
| #316 | Upload your existing certificates (PNG, JPG, SVG, PDF) to host them with CertPlatform for |
| #317 | digital verification and LinkedIn sharing. |
| #318 | </p> |
| #319 | |
| #320 | {/* Drop zone */} |
| #321 | <div |
| #322 | onDragOver={(e) => { |
| #323 | e.preventDefault(); |
| #324 | setDragOver(true); |
| #325 | }} |
| #326 | onDragLeave={() => setDragOver(false)} |
| #327 | onDrop={handleDrop} |
| #328 | onClick={() => fileRef.current?.click()} |
| #329 | className={`border-2 border-dashed rounded-2xl p-10 text-center cursor-pointer transition-colors mb-6 ${ |
| #330 | dragOver |
| #331 | ? "border-emerald-500 bg-emerald-50" |
| #332 | : "border-gray-300 bg-white hover:border-gray-400" |
| #333 | }`} |
| #334 | > |
| #335 | <Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" /> |
| #336 | <p className="text-sm font-medium text-gray-700 mb-1"> |
| #337 | Drop certificate files here or click to browse |
| #338 | </p> |
| #339 | <p className="text-xs text-gray-400"> |
| #340 | PNG, JPG, SVG, PDF · Max {MAX_FILE_SIZE_MB}MB each · Up to {MAX_FILES}{" "} |
| #341 | files |
| #342 | </p> |
| #343 | <p className="text-xs text-gray-400 mt-1"> |
| #344 | PDFs are automatically converted to images for hosting |
| #345 | </p> |
| #346 | <input |
| #347 | ref={fileRef} |
| #348 | type="file" |
| #349 | accept={ACCEPTED_EXTENSIONS} |
| #350 | multiple |
| #351 | onChange={handleFileChange} |
| #352 | className="hidden" |
| #353 | /> |
| #354 | </div> |
| #355 | |
| #356 | {/* File list */} |
| #357 | {items.length > 0 && ( |
| #358 | <div className="space-y-4 mb-6"> |
| #359 | <div className="flex items-center justify-between"> |
| #360 | <h2 className="font-semibold text-gray-900"> |
| #361 | {items.length} certificate{items.length !== 1 ? "s" : ""} selected |
| #362 | </h2> |
| #363 | <div className="flex gap-2"> |
| #364 | <button |
| #365 | onClick={() => fileRef.current?.click()} |
| #366 | className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" |
| #367 | > |
| #368 | <Plus className="w-3.5 h-3.5" /> Add More |
| #369 | </button> |
| #370 | <button |
| #371 | onClick={handleUpload} |
| #372 | disabled={uploading} |
| #373 | className="flex items-center gap-2 px-5 py-2 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white rounded-lg text-sm font-semibold transition-colors" |
| #374 | > |
| #375 | {uploading ? ( |
| #376 | <> |
| #377 | <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> |
| #378 | Uploading... |
| #379 | </> |
| #380 | ) : ( |
| #381 | <> |
| #382 | <Upload className="w-4 h-4" /> |
| #383 | Host All |
| #384 | </> |
| #385 | )} |
| #386 | </button> |
| #387 | </div> |
| #388 | </div> |
| #389 | |
| #390 | {items.map((item) => ( |
| #391 | <div |
| #392 | key={item.id} |
| #393 | className={`bg-white rounded-xl border p-4 transition-colors ${ |
| #394 | item.status === "done" |
| #395 | ? "border-emerald-300 bg-emerald-50/30" |
| #396 | : item.status === "error" |
| #397 | ? "border-red-300 bg-red-50/30" |
| #398 | : item.status === "uploading" |
| #399 | ? "border-blue-300 bg-blue-50/30" |
| #400 | : "border-gray-200" |
| #401 | }`} |
| #402 | > |
| #403 | <div className="flex gap-4"> |
| #404 | {/* Preview */} |
| #405 | <div className="w-32 h-24 shrink-0 bg-gray-100 rounded-lg overflow-hidden flex items-center justify-center"> |
| #406 | {item.file.type === "application/pdf" ? ( |
| #407 | <FileImage className="w-8 h-8 text-gray-400" /> |
| #408 | ) : ( |
| #409 | <img |
| #410 | src={item.preview} |
| #411 | alt="Preview" |
| #412 | className="w-full h-full object-contain" |
| #413 | /> |
| #414 | )} |
| #415 | </div> |
| #416 | |
| #417 | {/* Fields */} |
| #418 | <div className="flex-1 min-w-0"> |
| #419 | <div className="flex items-center justify-between mb-2"> |
| #420 | <span className="text-xs text-gray-500 font-mono truncate max-w-xs"> |
| #421 | {item.file.name} |
| #422 | </span> |
| #423 | {item.status === "pending" && ( |
| #424 | <button |
| #425 | onClick={() => removeItem(item.id)} |
| #426 | className="text-gray-400 hover:text-red-500 transition-colors" |
| #427 | > |
| #428 | <X className="w-4 h-4" /> |
| #429 | </button> |
| #430 | )} |
| #431 | {item.status === "done" && ( |
| #432 | <CheckCircle className="w-5 h-5 text-emerald-500" /> |
| #433 | )} |
| #434 | {item.status === "uploading" && ( |
| #435 | <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500" /> |
| #436 | )} |
| #437 | {item.status === "error" && ( |
| #438 | <div className="flex items-center gap-1"> |
| #439 | <span className="text-xs text-red-500">{item.error || "Failed"}</span> |
| #440 | <button |
| #441 | onClick={() => removeItem(item.id)} |
| #442 | className="text-gray-400 hover:text-red-500" |
| #443 | > |
| #444 | <X className="w-4 h-4" /> |
| #445 | </button> |
| #446 | </div> |
| #447 | )} |
| #448 | </div> |
| #449 | <div className="grid grid-cols-2 gap-2"> |
| #450 | <input |
| #451 | value={item.recipientName} |
| #452 | onChange={(e) => |
| #453 | updateItem(item.id, "recipientName", e.target.value) |
| #454 | } |
| #455 | placeholder="Recipient Name *" |
| #456 | disabled={item.status !== "pending"} |
| #457 | className="px-2.5 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 disabled:opacity-60" |
| #458 | /> |
| #459 | <input |
| #460 | value={item.courseTitle} |
| #461 | onChange={(e) => |
| #462 | updateItem(item.id, "courseTitle", e.target.value) |
| #463 | } |
| #464 | placeholder="Course Title *" |
| #465 | disabled={item.status !== "pending"} |
| #466 | className="px-2.5 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 disabled:opacity-60" |
| #467 | /> |
| #468 | <input |
| #469 | value={item.organization} |
| #470 | onChange={(e) => |
| #471 | updateItem(item.id, "organization", e.target.value) |
| #472 | } |
| #473 | placeholder="Organization" |
| #474 | disabled={item.status !== "pending"} |
| #475 | className="px-2.5 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 disabled:opacity-60" |
| #476 | /> |
| #477 | <input |
| #478 | type="date" |
| #479 | value={item.issueDate} |
| #480 | onChange={(e) => |
| #481 | updateItem(item.id, "issueDate", e.target.value) |
| #482 | } |
| #483 | disabled={item.status !== "pending"} |
| #484 | className="px-2.5 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 disabled:opacity-60" |
| #485 | /> |
| #486 | </div> |
| #487 | </div> |
| #488 | </div> |
| #489 | </div> |
| #490 | ))} |
| #491 | </div> |
| #492 | )} |
| #493 | </div> |
| #494 | ); |
| #495 | } |
| #496 |