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 playground16h ago| #1 | import { useEffect, useState } from "react"; |
| #2 | import { collection, getDocs, doc, updateDoc } from "firebase/firestore"; |
| #3 | import { db } from "../firebase"; |
| #4 | import { useToast } from "../components/Toast"; |
| #5 | import { Search, ExternalLink, Copy, Ban, CheckCircle, XCircle, FileImage } from "lucide-react"; |
| #6 | import type { Certificate } from "../types"; |
| #7 | |
| #8 | export default function CertsList() { |
| #9 | const [certs, setCerts] = useState<Certificate[]>([]); |
| #10 | const [loading, setLoading] = useState(true); |
| #11 | const [search, setSearch] = useState(""); |
| #12 | const [sortField, setSortField] = useState<"recipientName" | "courseTitle" | "issueDate" | "status">("issueDate"); |
| #13 | const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); |
| #14 | const { addToast } = useToast(); |
| #15 | |
| #16 | const fetchCerts = async () => { |
| #17 | try { |
| #18 | const snap = await getDocs(collection(db, "certificates")); |
| #19 | setCerts(snap.docs.map((d) => ({ id: d.id, ...d.data() } as Certificate))); |
| #20 | } catch (err: unknown) { |
| #21 | const msg = err instanceof Error ? err.message : String(err); |
| #22 | if (msg.includes("permission-denied")) { |
| #23 | addToast("Access denied — check Firestore security rules.", "error"); |
| #24 | } else { |
| #25 | addToast("Failed to load certificates.", "error"); |
| #26 | } |
| #27 | } finally { |
| #28 | setLoading(false); |
| #29 | } |
| #30 | }; |
| #31 | |
| #32 | useEffect(() => { fetchCerts(); }, []); |
| #33 | |
| #34 | const handleRevoke = async (id: string, name: string) => { |
| #35 | if (!window.confirm(`Revoke certificate for "${name}"? This action cannot be undone.`)) return; |
| #36 | try { |
| #37 | await updateDoc(doc(db, "certificates", id), { status: "revoked" }); |
| #38 | setCerts((prev) => prev.map((c) => (c.id === id ? { ...c, status: "revoked" } : c))); |
| #39 | addToast("Certificate revoked.", "success"); |
| #40 | } catch (err: unknown) { |
| #41 | const msg = err instanceof Error ? err.message : String(err); |
| #42 | if (msg.includes("permission-denied")) { |
| #43 | addToast("Access denied — check Firestore security rules.", "error"); |
| #44 | } else { |
| #45 | addToast("Failed to revoke certificate.", "error"); |
| #46 | } |
| #47 | } |
| #48 | }; |
| #49 | |
| #50 | const handleCopy = (id: string) => { |
| #51 | const url = `${window.location.origin}/#/verify/${id}`; |
| #52 | navigator.clipboard.writeText(url).then(() => addToast("URL copied!", "success")); |
| #53 | }; |
| #54 | |
| #55 | const toggleSort = (field: typeof sortField) => { |
| #56 | if (sortField === field) { |
| #57 | setSortDir((d) => (d === "asc" ? "desc" : "asc")); |
| #58 | } else { |
| #59 | setSortField(field); |
| #60 | setSortDir("asc"); |
| #61 | } |
| #62 | }; |
| #63 | |
| #64 | const filtered = certs |
| #65 | .filter((c) => { |
| #66 | const q = search.toLowerCase(); |
| #67 | return ( |
| #68 | c.recipientName.toLowerCase().includes(q) || |
| #69 | c.courseTitle.toLowerCase().includes(q) |
| #70 | ); |
| #71 | }) |
| #72 | .sort((a, b) => { |
| #73 | const dir = sortDir === "asc" ? 1 : -1; |
| #74 | if (sortField === "issueDate") return dir * (new Date(a.issueDate).getTime() - new Date(b.issueDate).getTime()); |
| #75 | return dir * String(a[sortField]).localeCompare(String(b[sortField])); |
| #76 | }); |
| #77 | |
| #78 | if (loading) { |
| #79 | return ( |
| #80 | <div className="flex items-center justify-center py-20"> |
| #81 | <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600" /> |
| #82 | </div> |
| #83 | ); |
| #84 | } |
| #85 | |
| #86 | return ( |
| #87 | <div> |
| #88 | <div className="flex items-center justify-between mb-6"> |
| #89 | <h1 className="text-2xl font-bold text-gray-900">All Certificates</h1> |
| #90 | <div className="relative"> |
| #91 | <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> |
| #92 | <input |
| #93 | value={search} |
| #94 | onChange={(e) => setSearch(e.target.value)} |
| #95 | placeholder="Search by name or course..." |
| #96 | className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 w-64" |
| #97 | /> |
| #98 | </div> |
| #99 | </div> |
| #100 | |
| #101 | <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> |
| #102 | <div className="overflow-x-auto"> |
| #103 | <table className="w-full text-sm"> |
| #104 | <thead> |
| #105 | <tr className="text-left text-gray-500 border-b border-gray-100 bg-gray-50"> |
| #106 | {([ |
| #107 | ["recipientName", "Recipient"], |
| #108 | ["courseTitle", "Course"], |
| #109 | ["issueDate", "Issue Date"], |
| #110 | ["status", "Status"], |
| #111 | ] as const).map(([field, label]) => ( |
| #112 | <th |
| #113 | key={field} |
| #114 | className="px-4 py-3 font-medium cursor-pointer select-none hover:text-gray-700" |
| #115 | onClick={() => toggleSort(field)} |
| #116 | > |
| #117 | {label}{" "} |
| #118 | {sortField === field && (sortDir === "asc" ? "↑" : "↓")} |
| #119 | </th> |
| #120 | ))} |
| #121 | <th className="px-4 py-3 font-medium">Template</th> |
| #122 | <th className="px-4 py-3 font-medium text-right">Actions</th> |
| #123 | </tr> |
| #124 | </thead> |
| #125 | <tbody> |
| #126 | {filtered.length === 0 ? ( |
| #127 | <tr> |
| #128 | <td colSpan={6} className="px-4 py-12 text-center text-gray-400"> |
| #129 | {search ? "No certificates match your search." : "No certificates found."} |
| #130 | </td> |
| #131 | </tr> |
| #132 | ) : ( |
| #133 | filtered.map((c) => ( |
| #134 | <tr key={c.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50"> |
| #135 | <td className="px-4 py-3 font-medium text-gray-900">{c.recipientName}</td> |
| #136 | <td className="px-4 py-3 text-gray-600">{c.courseTitle}</td> |
| #137 | <td className="px-4 py-3 text-gray-500"> |
| #138 | {new Date(c.issueDate).toLocaleDateString()} |
| #139 | </td> |
| #140 | <td className="px-4 py-3"> |
| #141 | <span |
| #142 | className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${ |
| #143 | c.status === "active" |
| #144 | ? "bg-emerald-50 text-emerald-700" |
| #145 | : "bg-red-50 text-red-700" |
| #146 | }`} |
| #147 | > |
| #148 | {c.status === "active" ? ( |
| #149 | <CheckCircle className="w-3 h-3" /> |
| #150 | ) : ( |
| #151 | <XCircle className="w-3 h-3" /> |
| #152 | )} |
| #153 | {c.status} |
| #154 | </span> |
| #155 | </td> |
| #156 | <td className="px-4 py-3 text-gray-500"> |
| #157 | {c.certType === "uploaded" ? ( |
| #158 | <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700"> |
| #159 | <FileImage className="w-3 h-3" /> Uploaded |
| #160 | </span> |
| #161 | ) : ( |
| #162 | <>#{c.templateId}</> |
| #163 | )} |
| #164 | </td> |
| #165 | <td className="px-4 py-3"> |
| #166 | <div className="flex items-center justify-end gap-1"> |
| #167 | <a |
| #168 | href={`/#/verify/${c.id}`} |
| #169 | target="_blank" |
| #170 | rel="noopener noreferrer" |
| #171 | className="p-1.5 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors" |
| #172 | title="View" |
| #173 | > |
| #174 | <ExternalLink className="w-4 h-4" /> |
| #175 | </a> |
| #176 | <button |
| #177 | onClick={() => handleCopy(c.id)} |
| #178 | className="p-1.5 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" |
| #179 | title="Copy URL" |
| #180 | > |
| #181 | <Copy className="w-4 h-4" /> |
| #182 | </button> |
| #183 | {c.status === "active" && ( |
| #184 | <button |
| #185 | onClick={() => handleRevoke(c.id, c.recipientName)} |
| #186 | className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors" |
| #187 | title="Revoke" |
| #188 | > |
| #189 | <Ban className="w-4 h-4" /> |
| #190 | </button> |
| #191 | )} |
| #192 | </div> |
| #193 | </td> |
| #194 | </tr> |
| #195 | )) |
| #196 | )} |
| #197 | </tbody> |
| #198 | </table> |
| #199 | </div> |
| #200 | </div> |
| #201 | <p className="text-xs text-gray-400 mt-3">{filtered.length} certificate{filtered.length !== 1 ? "s" : ""}</p> |
| #202 | </div> |
| #203 | ); |
| #204 | } |
| #205 |