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 { useState, useEffect, useRef } from "react"; |
| #2 | import CertificateCard from "../components/CertificateCard"; |
| #3 | import { CustomTemplatePreview } from "../components/CustomTemplateRenderer"; |
| #4 | import type { Certificate } from "../types"; |
| #5 | import { useCustomTemplates } from "../hooks/useCustomTemplates"; |
| #6 | import { X, Plus, Pencil, Trash2, Code, Eye } from "lucide-react"; |
| #7 | |
| #8 | const sampleCert: Certificate = { |
| #9 | id: "SAMPLE0001", |
| #10 | recipientName: "Jane Doe", |
| #11 | recipientEmail: "jane@example.com", |
| #12 | courseTitle: "Advanced Web Development", |
| #13 | organization: "Tech Academy", |
| #14 | issueDate: "2025-01-15", |
| #15 | expiryDate: null, |
| #16 | templateId: "1", |
| #17 | customText: "", |
| #18 | status: "active", |
| #19 | createdAt: "2025-01-15T00:00:00Z", |
| #20 | issuedBy: "", |
| #21 | }; |
| #22 | |
| #23 | const templateNames: Record<string, string> = { |
| #24 | "1": "Classic Academic", |
| #25 | "2": "Modern Minimal", |
| #26 | "3": "Tech Dark", |
| #27 | "4": "Corporate Blue", |
| #28 | "5": "Warm Earthy", |
| #29 | "6": "Vibrant Gradient", |
| #30 | "7": "Night Sky", |
| #31 | "8": "Clean Green", |
| #32 | "9": "Retro Bold", |
| #33 | "10": "Glass Morphism", |
| #34 | }; |
| #35 | |
| #36 | const BOILERPLATE = `// Available props: cert.id, cert.recipientName, cert.recipientEmail, |
| #37 | // cert.courseTitle, cert.organization, cert.issueDate, cert.expiryDate, |
| #38 | // cert.customText, cert.status, cert.templateId |
| #39 | // |
| #40 | // Return a JSX element. The outer div MUST be 1056x816 pixels. |
| #41 | return ( |
| #42 | <div |
| #43 | style={{ |
| #44 | width: 1056, |
| #45 | height: 816, |
| #46 | background: "#ffffff", |
| #47 | fontFamily: "'Inter', sans-serif", |
| #48 | display: "flex", |
| #49 | flexDirection: "column", |
| #50 | alignItems: "center", |
| #51 | justifyContent: "center", |
| #52 | position: "relative", |
| #53 | boxSizing: "border-box", |
| #54 | }} |
| #55 | > |
| #56 | <h1 style={{ fontSize: 36, color: "#111" }}> |
| #57 | {cert.courseTitle} |
| #58 | </h1> |
| #59 | <h2 style={{ fontSize: 48, color: "#333" }}> |
| #60 | {cert.recipientName} |
| #61 | </h2> |
| #62 | <p style={{ fontSize: 16, color: "#666" }}> |
| #63 | {cert.organization} — {cert.issueDate} |
| #64 | </p> |
| #65 | <p style={{ fontSize: 11, color: "#999", marginTop: 20 }}> |
| #66 | ID: {cert.id} |
| #67 | </p> |
| #68 | </div> |
| #69 | );`; |
| #70 | |
| #71 | export default function Templates() { |
| #72 | const { templates: customTemplates, addTemplate, updateTemplate, deleteTemplate } = useCustomTemplates(); |
| #73 | const [modalId, setModalId] = useState<string | null>(null); |
| #74 | const [editorOpen, setEditorOpen] = useState(false); |
| #75 | const [editId, setEditId] = useState<string | null>(null); |
| #76 | const [templateName, setTemplateName] = useState(""); |
| #77 | const [templateCode, setTemplateCode] = useState(BOILERPLATE); |
| #78 | const [previewCustom, setPreviewCustom] = useState<string | null>(null); |
| #79 | const [debouncedCode, setDebouncedCode] = useState(BOILERPLATE); |
| #80 | const debounceTimer = useRef<ReturnType<typeof setTimeout>>(); |
| #81 | |
| #82 | // Debounce preview updates to avoid recompiling on every keystroke |
| #83 | useEffect(() => { |
| #84 | if (!editorOpen) return; |
| #85 | clearTimeout(debounceTimer.current); |
| #86 | debounceTimer.current = setTimeout(() => setDebouncedCode(templateCode), 600); |
| #87 | return () => clearTimeout(debounceTimer.current); |
| #88 | }, [templateCode, editorOpen]); |
| #89 | |
| #90 | const openCreate = () => { |
| #91 | setEditId(null); |
| #92 | setTemplateName(""); |
| #93 | setTemplateCode(BOILERPLATE); |
| #94 | setDebouncedCode(BOILERPLATE); |
| #95 | setEditorOpen(true); |
| #96 | }; |
| #97 | |
| #98 | const openEdit = (id: string) => { |
| #99 | const t = customTemplates.find((ct) => ct.id === id); |
| #100 | if (!t) return; |
| #101 | setEditId(id); |
| #102 | setTemplateName(t.name); |
| #103 | setTemplateCode(t.code); |
| #104 | setDebouncedCode(t.code); |
| #105 | setEditorOpen(true); |
| #106 | }; |
| #107 | |
| #108 | const handleSave = () => { |
| #109 | const name = templateName.trim() || "Untitled Template"; |
| #110 | if (editId) { |
| #111 | updateTemplate(editId, name, templateCode); |
| #112 | } else { |
| #113 | addTemplate(name, templateCode); |
| #114 | } |
| #115 | setEditorOpen(false); |
| #116 | }; |
| #117 | |
| #118 | const handleDelete = (id: string) => { |
| #119 | if (confirm("Delete this custom template?")) { |
| #120 | deleteTemplate(id); |
| #121 | } |
| #122 | }; |
| #123 | |
| #124 | const previewCertForCustom = (id: string): Certificate => ({ ...sampleCert, templateId: id }); |
| #125 | |
| #126 | return ( |
| #127 | <div> |
| #128 | <h1 className="text-2xl font-bold text-gray-900 mb-6">Templates</h1> |
| #129 | |
| #130 | {/* Built-in templates */} |
| #131 | <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10"> |
| #132 | {Array.from({ length: 10 }, (_, i) => String(i + 1)).map((id) => { |
| #133 | const cert = { ...sampleCert, templateId: id }; |
| #134 | return ( |
| #135 | <div |
| #136 | key={id} |
| #137 | onClick={() => setModalId(id)} |
| #138 | className="bg-white rounded-xl border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow" |
| #139 | > |
| #140 | <p className="text-sm font-semibold text-gray-700 mb-3"> |
| #141 | #{id} — {templateNames[id]} |
| #142 | </p> |
| #143 | <div style={{ transform: "scale(0.35)", transformOrigin: "top left", width: 1056 * 0.35, height: 816 * 0.35 }}> |
| #144 | <CertificateCard cert={cert} /> |
| #145 | </div> |
| #146 | <div style={{ height: 816 * 0.35 }} /> |
| #147 | </div> |
| #148 | ); |
| #149 | })} |
| #150 | </div> |
| #151 | |
| #152 | {/* Custom Templates Section */} |
| #153 | <div className="flex items-center justify-between mb-4"> |
| #154 | <h2 className="text-lg font-bold text-gray-900">Custom Templates</h2> |
| #155 | <button |
| #156 | onClick={openCreate} |
| #157 | className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium transition-colors" |
| #158 | > |
| #159 | <Plus className="w-4 h-4" /> |
| #160 | Create Template |
| #161 | </button> |
| #162 | </div> |
| #163 | |
| #164 | {customTemplates.length === 0 ? ( |
| #165 | <div className="bg-white rounded-xl border border-dashed border-gray-300 p-10 text-center"> |
| #166 | <Code className="w-10 h-10 text-gray-300 mx-auto mb-3" /> |
| #167 | <p className="text-sm text-gray-500 mb-1">No custom templates yet</p> |
| #168 | <p className="text-xs text-gray-400"> |
| #169 | Create your own certificate design using TSX. Click "Create Template" to get started. |
| #170 | </p> |
| #171 | </div> |
| #172 | ) : ( |
| #173 | <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> |
| #174 | {customTemplates.map((t) => ( |
| #175 | <div key={t.id} className="bg-white rounded-xl border border-gray-200 p-4"> |
| #176 | <div className="flex items-center justify-between mb-3"> |
| #177 | <p className="text-sm font-semibold text-gray-700"> |
| #178 | {t.name} <span className="text-gray-400 font-normal">({t.id})</span> |
| #179 | </p> |
| #180 | <div className="flex gap-1"> |
| #181 | <button |
| #182 | onClick={() => setPreviewCustom(t.id)} |
| #183 | className="p-1.5 text-gray-400 hover:text-emerald-600 rounded" |
| #184 | title="Preview" |
| #185 | > |
| #186 | <Eye className="w-4 h-4" /> |
| #187 | </button> |
| #188 | <button |
| #189 | onClick={() => openEdit(t.id)} |
| #190 | className="p-1.5 text-gray-400 hover:text-blue-600 rounded" |
| #191 | title="Edit" |
| #192 | > |
| #193 | <Pencil className="w-4 h-4" /> |
| #194 | </button> |
| #195 | <button |
| #196 | onClick={() => handleDelete(t.id)} |
| #197 | className="p-1.5 text-gray-400 hover:text-red-600 rounded" |
| #198 | title="Delete" |
| #199 | > |
| #200 | <Trash2 className="w-4 h-4" /> |
| #201 | </button> |
| #202 | </div> |
| #203 | </div> |
| #204 | <div style={{ transform: "scale(0.35)", transformOrigin: "top left", width: 1056 * 0.35, height: 816 * 0.35 }}> |
| #205 | <CertificateCard cert={previewCertForCustom(t.id)} /> |
| #206 | </div> |
| #207 | <div style={{ height: 816 * 0.35 }} /> |
| #208 | </div> |
| #209 | ))} |
| #210 | </div> |
| #211 | )} |
| #212 | |
| #213 | {/* Template Editor Modal */} |
| #214 | {editorOpen && ( |
| #215 | <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setEditorOpen(false)}> |
| #216 | <div |
| #217 | className="bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col" |
| #218 | onClick={(e) => e.stopPropagation()} |
| #219 | > |
| #220 | <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> |
| #221 | <h3 className="text-lg font-bold text-gray-900"> |
| #222 | {editId ? "Edit Custom Template" : "Create Custom Template"} |
| #223 | </h3> |
| #224 | <button onClick={() => setEditorOpen(false)} className="p-2 hover:bg-gray-100 rounded-full"> |
| #225 | <X className="w-5 h-5 text-gray-500" /> |
| #226 | </button> |
| #227 | </div> |
| #228 | |
| #229 | <div className="flex-1 overflow-auto p-6 space-y-4"> |
| #230 | {/* Name */} |
| #231 | <div> |
| #232 | <label className="block text-sm font-medium text-gray-700 mb-1">Template Name</label> |
| #233 | <input |
| #234 | value={templateName} |
| #235 | onChange={(e) => setTemplateName(e.target.value)} |
| #236 | placeholder="My Custom Template" |
| #237 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" |
| #238 | /> |
| #239 | </div> |
| #240 | |
| #241 | {/* Code Editor */} |
| #242 | <div> |
| #243 | <label className="block text-sm font-medium text-gray-700 mb-1"> |
| #244 | Template Code (TSX) |
| #245 | </label> |
| #246 | <p className="text-xs text-gray-400 mb-2"> |
| #247 | Write the body of a function that receives <code>cert</code> as a parameter. Must return JSX with a 1056×816 root element. |
| #248 | </p> |
| #249 | <textarea |
| #250 | value={templateCode} |
| #251 | onChange={(e) => setTemplateCode(e.target.value)} |
| #252 | spellCheck={false} |
| #253 | className="w-full h-80 px-4 py-3 border border-gray-300 rounded-lg text-sm font-mono leading-relaxed bg-gray-900 text-green-400 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 resize-none" |
| #254 | /> |
| #255 | </div> |
| #256 | |
| #257 | {/* Live Preview */} |
| #258 | <div> |
| #259 | <label className="block text-sm font-medium text-gray-700 mb-2">Live Preview</label> |
| #260 | <div className="border border-gray-200 rounded-lg overflow-auto bg-gray-50 p-4"> |
| #261 | <div style={{ transform: "scale(0.4)", transformOrigin: "top left", width: 1056 * 0.4, height: 816 * 0.4 }}> |
| #262 | <CustomTemplatePreview code={debouncedCode} cert={sampleCert} /> |
| #263 | </div> |
| #264 | <div style={{ height: 816 * 0.4 }} /> |
| #265 | </div> |
| #266 | </div> |
| #267 | </div> |
| #268 | |
| #269 | <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200"> |
| #270 | <button |
| #271 | onClick={() => setEditorOpen(false)} |
| #272 | className="px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" |
| #273 | > |
| #274 | Cancel |
| #275 | </button> |
| #276 | <button |
| #277 | onClick={handleSave} |
| #278 | className="px-5 py-2.5 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors" |
| #279 | > |
| #280 | {editId ? "Update Template" : "Save Template"} |
| #281 | </button> |
| #282 | </div> |
| #283 | </div> |
| #284 | </div> |
| #285 | )} |
| #286 | |
| #287 | {/* Full Preview Modal */} |
| #288 | {(modalId || previewCustom) && ( |
| #289 | <div |
| #290 | className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-8" |
| #291 | onClick={() => { setModalId(null); setPreviewCustom(null); }} |
| #292 | > |
| #293 | <div |
| #294 | className="relative bg-white rounded-2xl shadow-2xl overflow-auto max-h-full max-w-full" |
| #295 | onClick={(e) => e.stopPropagation()} |
| #296 | > |
| #297 | <button |
| #298 | onClick={() => { setModalId(null); setPreviewCustom(null); }} |
| #299 | className="absolute top-4 right-4 z-10 p-2 bg-white/80 rounded-full hover:bg-white shadow" |
| #300 | > |
| #301 | <X className="w-5 h-5 text-gray-600" /> |
| #302 | </button> |
| #303 | <p className="text-center text-sm font-medium text-gray-500 pt-4 pb-2"> |
| #304 | {modalId |
| #305 | ? `Template #${modalId} — ${templateNames[modalId]}` |
| #306 | : customTemplates.find((t) => t.id === previewCustom)?.name ?? "Custom Template"} |
| #307 | </p> |
| #308 | <CertificateCard cert={{ ...sampleCert, templateId: modalId ?? previewCustom ?? "1" }} /> |
| #309 | </div> |
| #310 | </div> |
| #311 | )} |
| #312 | </div> |
| #313 | ); |
| #314 | } |
| #315 |