ysharma HF Staff commited on
Commit
794a9f2
Β·
verified Β·
1 Parent(s): 0e73063

Build Small certificate generator

Browse files
Files changed (5) hide show
  1. README.md +20 -8
  2. app.py +344 -0
  3. certificate_template.html +185 -0
  4. certificate_upload_module.py +99 -0
  5. requirements.txt +3 -0
README.md CHANGED
@@ -1,13 +1,25 @@
1
  ---
2
- title: Certificate Generator
3
- emoji: πŸ“‰
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Build Small Hackathon Certificate Generator
3
+ emoji: πŸͺ΅
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.1.0
 
8
  app_file: app.py
9
+ pinned: true
10
+ license: mit
11
+ hf_oauth: true
12
+ short_description: Claim your Build Small Hackathon certificate
13
  ---
14
 
15
+ # Build Small β€” Certificate Generator
16
+
17
+ Sign in with Hugging Face to generate your **Build Small Hackathon** certificate of
18
+ participation. Your name and (if you shipped a Space) your project are auto-filled from the
19
+ hackathon records β€” all fields are editable before you generate.
20
+
21
+ Eligibility is validated against `build-small-hackathon/build-small-apps-for-certificates`
22
+ (registered participants βˆͺ org Space contributors). Generated certificates are saved to the
23
+ public gallery dataset `build-small-hackathon/build-small-certificates`.
24
+
25
+ Check the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build Small Hackathon β€” Certificate Generator
3
+ =============================================
4
+ Workflow:
5
+ 1. User signs in with Hugging Face (OAuth).
6
+ 2. We take their HF username and look it up in the private eligibility dataset
7
+ `build-small-hackathon/build-small-apps-for-certificates`
8
+ (registered participants βˆͺ org Space contributors).
9
+ β€’ username found WITH a space -> autofill full name + project name
10
+ β€’ username found WITHOUT a space -> autofill full name, no project
11
+ β€’ username not found -> show contact message, do not proceed
12
+ 3. All autofilled fields are editable.
13
+ 4. A button generates the certificate (HTML -> PNG via the renderer Space) and
14
+ saves it to the public gallery dataset `build-small-hackathon/build-small-certificates`.
15
+ """
16
+ import os
17
+ import uuid
18
+ import tempfile
19
+ import urllib.parse
20
+ from functools import lru_cache
21
+
22
+ import gradio as gr
23
+ import pandas as pd
24
+ from PIL import Image
25
+ from datasets import load_dataset
26
+ from gradio_client import Client, handle_file
27
+
28
+ from certificate_upload_module import upload_user_certificate
29
+
30
+ HF_TOKEN = os.getenv("HF_TOKEN")
31
+
32
+ ELIGIBILITY_DATASET = "build-small-hackathon/build-small-apps-for-certificates"
33
+ RENDERER_SPACE = "https://ysharma-hackathon-certificate-html-to-image.hf.space/"
34
+
35
+ DISCORD_INVITE = "https://discord.gg/92sEPT2Zhv"
36
+ CONTACT_EMAIL = "hello@gradio.app"
37
+
38
+ _TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "certificate_template.html")
39
+ with open(_TEMPLATE_PATH, encoding="utf-8") as _f:
40
+ CERTIFICATE_HTML_TEMPLATE = _f.read()
41
+
42
+ PROJECT_SECTION_WITH_NAME = '<div class="project"><span class="lbl">Project</span> {project_name}</div>'
43
+ PROJECT_SECTION_EMPTY = ""
44
+
45
+
46
+ # ----------------------------------------------------------------------------- renderer client
47
+ _gradio_client = None
48
+
49
+
50
+ def get_gradio_client():
51
+ """Lazy init of the HTML->image renderer client (avoids event-loop issues)."""
52
+ global _gradio_client
53
+ if _gradio_client is None:
54
+ _gradio_client = Client(RENDERER_SPACE, hf_token=HF_TOKEN)
55
+ return _gradio_client
56
+
57
+
58
+ # ----------------------------------------------------------------------------- eligibility lookup
59
+ @lru_cache(maxsize=1)
60
+ def _eligibility_index():
61
+ """Load the eligibility dataset once into {username_lower: (full_name, space_name)}."""
62
+ ds = load_dataset(ELIGIBILITY_DATASET, split="train", token=HF_TOKEN)
63
+ df = ds.to_pandas().fillna("")
64
+ index = {}
65
+ for _, row in df.iterrows():
66
+ uname = str(row.get("hf_username", "")).strip()
67
+ if not uname:
68
+ continue
69
+ index[uname.lower()] = (
70
+ str(row.get("full_name", "")).strip(),
71
+ str(row.get("space_name", "")).strip(),
72
+ )
73
+ return index
74
+
75
+
76
+ def lookup_participant(username: str):
77
+ """Return (state, full_name, space_name).
78
+
79
+ state ∈ {"found_project", "found_no_project", "not_found"}.
80
+ """
81
+ if not username:
82
+ return "not_found", "", ""
83
+ try:
84
+ index = _eligibility_index()
85
+ except Exception as e:
86
+ print(f"[ERROR] eligibility load failed: {e}")
87
+ return "error", "", ""
88
+
89
+ entry = index.get(username.strip().lower())
90
+ if entry is None:
91
+ return "not_found", "", ""
92
+ full_name, space_name = entry
93
+ if space_name:
94
+ return "found_project", full_name, space_name
95
+ return "found_no_project", full_name, ""
96
+
97
+
98
+ # ----------------------------------------------------------------------------- on login
99
+ def on_load(profile: gr.OAuthProfile | None):
100
+ """Runs on page load. Drives which panel is shown and pre-fills the form."""
101
+ if profile is None:
102
+ return (
103
+ "Please sign in with your Hugging Face account to continue.",
104
+ gr.update(visible=False), # main_interface
105
+ gr.update(visible=False), # contact_box
106
+ "", # name
107
+ gr.update(value="", visible=True), # project
108
+ "", # data_status
109
+ )
110
+
111
+ username = profile.username
112
+ profile_name = profile.name or username
113
+ state, full_name, space_name = lookup_participant(username)
114
+
115
+ if state == "not_found":
116
+ return (
117
+ f"Signed in as **{username}**.",
118
+ gr.update(visible=False),
119
+ gr.update(visible=True),
120
+ "",
121
+ gr.update(value="", visible=True),
122
+ "",
123
+ )
124
+ if state == "error":
125
+ return (
126
+ f"Signed in as **{username}**.",
127
+ gr.update(visible=False),
128
+ gr.update(visible=True),
129
+ "",
130
+ gr.update(value="", visible=True),
131
+ "",
132
+ )
133
+
134
+ name_value = full_name or profile_name
135
+
136
+ if state == "found_project":
137
+ status = f"βœ… Found your submission β€” **{space_name}**. Review the details below, then generate."
138
+ project_update = gr.update(value=space_name, visible=True)
139
+ else: # found_no_project
140
+ status = (
141
+ "βœ… You're on the participant list! We couldn't find a Space submission under your "
142
+ "name β€” you can add a project below or leave it blank."
143
+ )
144
+ project_update = gr.update(value="", visible=True)
145
+
146
+ return (
147
+ f"Signed in as **{username}**.",
148
+ gr.update(visible=True),
149
+ gr.update(visible=False),
150
+ name_value,
151
+ project_update,
152
+ status,
153
+ )
154
+
155
+
156
+ # ----------------------------------------------------------------------------- LinkedIn helper
157
+ def generate_linkedin_url(participant_name, project_name):
158
+ params = {
159
+ "startTask": "CERTIFICATION_NAME",
160
+ "name": "Build Small Hackathon 2026",
161
+ "organizationName": "Hugging Face",
162
+ "issueYear": "2026",
163
+ "issueMonth": "6",
164
+ }
165
+ return "https://www.linkedin.com/profile/add?" + urllib.parse.urlencode(
166
+ params, quote_via=urllib.parse.quote
167
+ )
168
+
169
+
170
+ # ----------------------------------------------------------------------------- generate
171
+ def create_certificate(participant_name, project_name,
172
+ oauth_token: gr.OAuthToken | None, profile: gr.OAuthProfile | None):
173
+ if profile is None:
174
+ return None, None, "❌ Please sign in first to generate your certificate.", ""
175
+
176
+ username = profile.username
177
+ state, _, _ = lookup_participant(username)
178
+ if state == "not_found":
179
+ return None, None, (
180
+ "❌ We couldn't find you in the Build Small Hackathon records, so we can't issue a "
181
+ f"certificate. Please reach out on [Discord]({DISCORD_INVITE}) or email {CONTACT_EMAIL}."
182
+ ), ""
183
+ if state == "error":
184
+ return None, None, "❌ Couldn't reach the hackathon records right now. Please try again shortly.", ""
185
+
186
+ participant_name = (participant_name or "").strip() or (profile.name or username)
187
+ project_name = (project_name or "").strip()
188
+
189
+ project_section = (
190
+ PROJECT_SECTION_WITH_NAME.replace("{project_name}", project_name)
191
+ if project_name else PROJECT_SECTION_EMPTY
192
+ )
193
+
194
+ html = CERTIFICATE_HTML_TEMPLATE.replace("{participant_name}", participant_name)
195
+ html = html.replace("{project_section}", project_section)
196
+
197
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as f:
198
+ f.write(html)
199
+ html_path = f.name
200
+
201
+ try:
202
+ client = get_gradio_client()
203
+ image_path = client.predict(html_file=handle_file(html_path), api_name="/predict")[0]
204
+ except Exception as e:
205
+ return None, None, f"❌ Error generating certificate image: {e}", ""
206
+
207
+ # Save to the public gallery dataset
208
+ try:
209
+ cert_image = Image.open(image_path)
210
+ ok, msg = upload_user_certificate(cert_image, username)
211
+ save_note = msg if ok else f"(generated β€” gallery save note: {msg})"
212
+ except Exception as e:
213
+ save_note = f"(generated β€” gallery save skipped: {e})"
214
+
215
+ linkedin_url = generate_linkedin_url(participant_name, project_name)
216
+ status = f"πŸŽ‰ Your certificate is ready, {participant_name}! {save_note}"
217
+ return image_path, image_path, status, linkedin_url
218
+
219
+
220
+ def render_linkedin_button(url):
221
+ if not url:
222
+ return "<div style='padding:.5rem;color:var(--ink-soft)'>Generate your certificate to enable LinkedIn sharing.</div>"
223
+ return f"""
224
+ <a href="{url}" target="_blank" class="bs-linkedin">Add to LinkedIn profile β†’</a>
225
+ <p style="margin-top:8px;font-size:.85rem;color:#6b6655">
226
+ Opens LinkedIn with the certification pre-filled β€” then upload your downloaded image.</p>
227
+ """
228
+
229
+
230
+ # ----------------------------------------------------------------------------- theming
231
+ CUSTOM_CSS = """
232
+ @import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&family=Spline+Sans+Mono:wght@400;500;600&display=swap');
233
+ :root{
234
+ --paper:#f4eee1; --kraft:#e4d5b7; --kraft-deep:#d6c19a; --line:#cdbb95;
235
+ --ink:#33312b; --ink-soft:rgba(51,49,43,.62); --forest:#3d6a55; --forest-ink:#20392d; --amber:#e0913a;
236
+ }
237
+ .gradio-container{max-width:880px !important;margin:auto !important;
238
+ font-family:'Archivo',system-ui,sans-serif !important;background:var(--paper) !important;}
239
+ .gradio-container .prose, .gradio-container label, .gradio-container p{color:var(--ink) !important;}
240
+ .bs-hero{position:relative;overflow:hidden;background:var(--kraft);border:2px solid var(--ink);
241
+ box-shadow:6px 6px 0 var(--ink);padding:30px 34px;margin-bottom:22px;}
242
+ .bs-hero .kick{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.24em;
243
+ text-transform:uppercase;color:var(--forest);font-weight:600;}
244
+ .bs-hero h1{font-family:'Archivo';font-weight:900;font-stretch:120%;font-size:46px;line-height:.95;
245
+ letter-spacing:-.02em;color:var(--ink);margin:8px 0 6px;}
246
+ .bs-hero p{color:#5a5446;font-size:15px;margin:0;}
247
+ .bs-section{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.18em;
248
+ text-transform:uppercase;color:var(--ink-soft);font-weight:600;margin:6px 0 2px;display:flex;
249
+ align-items:center;gap:9px;}
250
+ .bs-section::before{content:"";width:20px;height:2px;background:var(--amber);display:inline-block;}
251
+ button.primary, .bs-generate button{background:var(--forest) !important;border:2px solid var(--forest-ink) !important;
252
+ color:#fff !important;border-radius:0 !important;font-family:'Archivo' !important;font-weight:800 !important;
253
+ box-shadow:4px 4px 0 var(--forest-ink) !important;transition:transform .1s,box-shadow .1s !important;}
254
+ .bs-generate button:hover{transform:translate(2px,2px) !important;box-shadow:2px 2px 0 var(--forest-ink) !important;}
255
+ .bs-contact{background:#fbeee0;border:2px solid var(--amber);box-shadow:4px 4px 0 var(--ink);padding:18px 22px;}
256
+ .bs-contact a{color:var(--forest);font-weight:700;}
257
+ .bs-linkedin{display:inline-block;background:var(--ink);color:var(--paper) !important;text-decoration:none;
258
+ font-weight:800;padding:11px 20px;border:2px solid var(--ink);box-shadow:3px 3px 0 var(--amber);}
259
+ input, textarea{border-radius:0 !important;border:2px solid var(--ink) !important;
260
+ background:#fff !important;font-family:'Spline Sans Mono',monospace !important;}
261
+ footer{display:none !important;}
262
+ """
263
+
264
+ BS_THEME = gr.themes.Base(
265
+ primary_hue=gr.themes.colors.green,
266
+ secondary_hue=gr.themes.colors.orange,
267
+ neutral_hue=gr.themes.colors.stone,
268
+ font=gr.themes.GoogleFont("Archivo"),
269
+ ).set(button_large_radius="0px", button_small_radius="0px", block_radius="0px")
270
+
271
+
272
+ # ----------------------------------------------------------------------------- UI
273
+ with gr.Blocks(title="Build Small β€” Certificate Generator", theme=BS_THEME, css=CUSTOM_CSS) as demo:
274
+ gr.HTML(
275
+ """
276
+ <div class="bs-hero">
277
+ <div class="kick">Hugging Face Γ— Gradio Β· 2026</div>
278
+ <h1>Build Small β€” Certificate</h1>
279
+ <p>Sign in with Hugging Face to claim your certificate of participation. Built something
280
+ small, local, and yours? Let's make it official.</p>
281
+ </div>
282
+ """
283
+ )
284
+
285
+ with gr.Group():
286
+ login_btn = gr.LoginButton(value="Sign in with Hugging Face")
287
+ login_status = gr.Markdown("Please sign in with your Hugging Face account to continue.")
288
+
289
+ contact_box = gr.HTML(
290
+ f"""
291
+ <div class="bs-contact">
292
+ <strong>We couldn't find you in the Build Small Hackathon records.</strong><br>
293
+ Certificates are issued to registered participants and Build Small org Space contributors.
294
+ If you think this is a mistake, reach out on
295
+ <a href="{DISCORD_INVITE}" target="_blank">Discord</a> or email
296
+ <a href="mailto:{CONTACT_EMAIL}">{CONTACT_EMAIL}</a>.
297
+ </div>
298
+ """,
299
+ visible=False,
300
+ )
301
+
302
+ with gr.Column(visible=False) as main_interface:
303
+ gr.HTML('<div class="bs-section">Your details</div>')
304
+ data_status = gr.Markdown("")
305
+ participant_name = gr.Textbox(
306
+ label="Full name",
307
+ info="This appears on your certificate β€” edit if you'd like it shown differently.",
308
+ )
309
+ project_name = gr.Textbox(
310
+ label="Project / Space name (optional)",
311
+ info="Auto-filled from your Build Small submission. Leave blank for a certificate without a project.",
312
+ )
313
+ with gr.Row(elem_classes=["bs-generate"]):
314
+ generate_btn = gr.Button("Generate my certificate", variant="primary", size="lg")
315
+
316
+ gr.HTML('<div class="bs-section">Your certificate</div>')
317
+ certificate_image = gr.Image(label="Preview", type="filepath", interactive=False, show_download_button=True)
318
+ certificate_file = gr.File(label="Download (PNG)", interactive=False)
319
+ generation_status = gr.Markdown("")
320
+
321
+ gr.HTML('<div class="bs-section">Share</div>')
322
+ linkedin_html = gr.HTML(render_linkedin_button(""))
323
+
324
+ linkedin_state = gr.State("")
325
+
326
+ demo.load(
327
+ fn=on_load,
328
+ inputs=None,
329
+ outputs=[login_status, main_interface, contact_box, participant_name, project_name, data_status],
330
+ )
331
+
332
+ generate_btn.click(
333
+ fn=create_certificate,
334
+ inputs=[participant_name, project_name],
335
+ outputs=[certificate_image, certificate_file, generation_status, linkedin_state],
336
+ ).then(
337
+ fn=lambda url: render_linkedin_button(url),
338
+ inputs=[linkedin_state],
339
+ outputs=[linkedin_html],
340
+ )
341
+
342
+
343
+ if __name__ == "__main__":
344
+ demo.launch()
certificate_template.html ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Build Small β€” Certificate</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@100..125,400..900&family=Spline+Sans+Mono:wght@400;500;600&display=swap');
9
+
10
+ :root{
11
+ --paper:#f4eee1; --paper-2:#efe7d4; --kraft:#e4d5b7; --kraft-deep:#d6c19a; --line:#cdbb95;
12
+ --ink:#33312b; --ink-2:#4a4639; --ink-soft:rgba(51,49,43,.62); --ink-faint:rgba(51,49,43,.40);
13
+ --forest:#3d6a55; --forest-deep:#2b4d3d; --forest-ink:#20392d;
14
+ --amber:#e0913a; --amber-deep:#c2762a; --clay:#bd5f37;
15
+ --font-display:'Archivo',system-ui,sans-serif;
16
+ --font-mono:'Spline Sans Mono',ui-monospace,monospace;
17
+ }
18
+ *{margin:0;padding:0;box-sizing:border-box;}
19
+ body{width:2000px;height:1414px;overflow:hidden;}
20
+
21
+ .certificate{
22
+ width:2000px;height:1414px;position:relative;overflow:hidden;
23
+ background:var(--paper);color:var(--ink);
24
+ font-family:var(--font-display);
25
+ print-color-adjust:exact;-webkit-print-color-adjust:exact;
26
+ }
27
+
28
+ /* paper grain (from the field-guide global.css) */
29
+ .grain{position:absolute;inset:0;z-index:1;pointer-events:none;opacity:.5;
30
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E");}
31
+
32
+ /* topographic contour backdrops β€” corners, forest tint */
33
+ .topo{position:absolute;z-index:1;pointer-events:none;opacity:.32;}
34
+ .topo--br{right:-260px;bottom:-300px;width:1100px;height:1100px;}
35
+ .topo--tl{left:-320px;top:-340px;width:1000px;height:1000px;opacity:.22;}
36
+
37
+ /* frame */
38
+ .frame{position:absolute;inset:46px;z-index:3;border:3px solid var(--ink);background:transparent;}
39
+ .frame::after{content:"";position:absolute;inset:13px;border:2px solid var(--ink-faint);pointer-events:none;}
40
+
41
+ /* registration corner ticks */
42
+ .tick{position:absolute;z-index:5;width:46px;height:46px;}
43
+ .tick path{stroke:var(--ink);stroke-width:3;fill:none;}
44
+ .tick--tl{top:30px;left:30px;} .tick--tr{top:30px;right:30px;transform:scaleX(-1);}
45
+ .tick--bl{bottom:30px;left:30px;transform:scaleY(-1);} .tick--br{bottom:30px;right:30px;transform:scale(-1,-1);}
46
+
47
+ .content{position:relative;z-index:4;height:100%;padding:60px 120px 50px;
48
+ display:flex;flex-direction:column;text-align:center;}
49
+
50
+ /* ---- top bar ---- */
51
+ .topbar{display:flex;justify-content:space-between;align-items:center;}
52
+ .eyebrow{font-family:var(--font-mono);font-size:18px;font-weight:500;letter-spacing:.30em;
53
+ text-transform:uppercase;color:var(--ink-soft);display:inline-flex;align-items:center;gap:14px;}
54
+ .eyebrow::before{content:"";width:34px;height:2px;background:var(--amber);display:inline-block;}
55
+ .coord{font-family:var(--font-mono);font-size:16px;letter-spacing:.10em;color:var(--ink-faint);}
56
+
57
+ /* ---- lockup ---- */
58
+ .lockup{display:flex;align-items:center;justify-content:center;gap:34px;margin-top:30px;}
59
+ .lockup img{height:48px;display:block;}
60
+ .lockup .hf-wm{font-weight:800;font-stretch:112%;font-size:34px;letter-spacing:-.01em;color:var(--ink);}
61
+ .lockup .cross{font-family:var(--font-mono);font-size:30px;color:var(--ink-faint);font-weight:400;}
62
+ .hf-mark{display:inline-flex;align-items:center;gap:16px;}
63
+
64
+ /* ---- title ---- */
65
+ .kicker{font-family:var(--font-mono);font-size:19px;font-weight:600;letter-spacing:.26em;
66
+ text-transform:uppercase;color:var(--forest);margin-top:30px;}
67
+ .title{font-weight:900;font-stretch:122%;font-size:142px;line-height:.92;letter-spacing:-.02em;
68
+ margin-top:12px;color:var(--ink);}
69
+ .subtitle{font-family:var(--font-mono);font-size:21px;font-weight:500;letter-spacing:.42em;
70
+ text-transform:uppercase;color:var(--ink-2);margin-top:16px;}
71
+
72
+ /* ---- body ---- */
73
+ .body{flex-grow:1;display:flex;flex-direction:column;justify-content:center;}
74
+ .certifies{font-family:var(--font-mono);font-size:23px;color:var(--ink-soft);letter-spacing:.04em;margin-bottom:6px;}
75
+ .name{font-weight:800;font-stretch:105%;font-size:98px;line-height:1;letter-spacing:-.01em;
76
+ color:var(--forest-deep);padding:14px 0 20px;display:inline-block;margin:6px auto 0;
77
+ border-bottom:3px solid var(--ink);}
78
+ .desc{font-size:25px;line-height:1.58;color:var(--ink-2);max-width:1300px;margin:32px auto 0;font-weight:500;text-wrap:pretty;}
79
+ .desc strong{font-weight:800;color:var(--ink);}
80
+ .project{margin:22px auto 0;display:inline-flex;align-items:center;gap:16px;
81
+ font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--ink);
82
+ background:var(--kraft);border:2px solid var(--ink);box-shadow:5px 5px 0 var(--ink);
83
+ padding:18px 34px;}
84
+ .project .lbl{color:var(--forest);font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:18px;}
85
+
86
+ /* ---- stats ---- */
87
+ .stats{display:flex;justify-content:center;width:fit-content;margin:32px auto 0;
88
+ border:2px solid var(--ink);background:var(--paper);box-shadow:6px 6px 0 var(--ink);}
89
+ .stat{padding:18px 50px;border-right:2px solid var(--ink);}
90
+ .stat:last-child{border-right:none;}
91
+ .stat b{display:block;font-weight:800;font-stretch:118%;font-size:44px;line-height:1;color:var(--ink);}
92
+ .stat span{display:block;font-family:var(--font-mono);font-size:15px;letter-spacing:.10em;
93
+ text-transform:uppercase;color:var(--ink-soft);margin-top:10px;}
94
+
95
+ /* ---- signature row ---- */
96
+ .sigrow{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;margin-top:0;padding-top:30px;}
97
+ .sig{display:flex;flex-direction:column;gap:10px;}
98
+ .sig--l{align-items:flex-start;text-align:left;} .sig--r{align-items:flex-end;text-align:right;}
99
+ .sig__val{font-weight:800;font-stretch:110%;font-size:26px;color:var(--ink);}
100
+ .sig__lbl{font-family:var(--font-mono);font-size:15px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft);}
101
+ .sig__rule{width:280px;height:0;border-top:2px solid var(--ink);margin-bottom:2px;}
102
+
103
+ /* circular stamp seal */
104
+ .seal{width:152px;height:152px;position:relative;transform:rotate(-7deg);}
105
+
106
+ /* ---- sponsors ---- */
107
+ .sponsors{margin-top:24px;padding-top:22px;border-top:2px dashed var(--line);}
108
+ .sponsors__t{font-family:var(--font-mono);font-size:15px;letter-spacing:.24em;text-transform:uppercase;
109
+ color:var(--ink-faint);margin-bottom:18px;}
110
+ .sponsors__l{font-weight:800;font-stretch:108%;font-size:31px;color:var(--ink);letter-spacing:-.005em;}
111
+ .sponsors__l i{color:var(--kraft-deep);font-style:normal;font-weight:400;margin:0 16px;
112
+ position:relative;top:-3px;font-size:24px;}
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <div class="certificate">
117
+ <div class="grain"></div>
118
+
119
+ <!-- topo contour backdrops -->
120
+ <svg class="topo topo--br" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#3d6a55" stroke-width="1.4">
121
+ <path d="M200 40C120 60 60 120 50 210s40 150 130 150 160-70 150-160S280 20 200 40Z"/>
122
+ <path d="M200 80C140 96 96 142 90 210s34 116 110 116 128-54 122-120S262 64 200 80Z"/>
123
+ <path d="M200 120C158 132 128 166 124 212s26 84 78 84 96-40 92-86S240 108 200 120Z"/>
124
+ <path d="M200 160C176 168 158 188 156 214s16 52 46 52 58-24 56-50S224 152 200 160Z"/>
125
+ <path d="M200 196c-14 4-24 16-24 30s12 26 26 26 28-12 28-28-16-30-30-28Z"/>
126
+ </svg>
127
+ <svg class="topo topo--tl" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#e0913a" stroke-width="1.4">
128
+ <path d="M200 40C120 60 60 120 50 210s40 150 130 150 160-70 150-160S280 20 200 40Z"/>
129
+ <path d="M200 90C146 104 104 146 98 208s32 110 104 110 122-50 116-114S256 76 200 90Z"/>
130
+ <path d="M200 140C166 150 142 180 138 214s22 74 68 74 84-34 80-74S232 130 200 140Z"/>
131
+ <path d="M200 190c-18 4-32 20-32 38s14 32 34 32 34-16 34-36-18-38-36-34Z"/>
132
+ </svg>
133
+
134
+ <div class="frame"></div>
135
+ <svg class="tick tick--tl" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
136
+ <svg class="tick tick--tr" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
137
+ <svg class="tick tick--bl" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
138
+ <svg class="tick tick--br" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
139
+
140
+ <div class="content">
141
+ <div class="topbar">
142
+ <span class="eyebrow">Certificate of Participation</span>
143
+ <span class="coord">June 15, 2026</span>
144
+ </div>
145
+
146
+ <div class="lockup">
147
+ <span class="hf-mark">
148
+ <img src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg" alt="Hugging Face">
149
+ <span class="hf-wm">Hugging&nbsp;Face</span>
150
+ </span>
151
+ <span class="cross">Γ—</span>
152
+ <img src="https://www.gradio.app/_app/immutable/assets/gradio-logo-with-title.3SNGTZpF.svg" alt="Gradio">
153
+ </div>
154
+
155
+ <div>
156
+ <div class="kicker">Hugging Face Γ— Gradio Hackathon</div>
157
+ <div class="title">Build Small</div>
158
+ <div class="subtitle">Small Β· Local Β· Yours</div>
159
+ </div>
160
+
161
+ <div class="body">
162
+ <div class="certifies">This certifies that</div>
163
+ <div class="name" id="participantName">{participant_name}</div>
164
+ <div class="desc">
165
+ has successfully participated in <strong>Build Small</strong>, a Hugging&nbsp;Face&nbsp;Γ—&nbsp;Gradio
166
+ hackathon celebrating small, local, open-weight models β€” building real, tinkerable apps
167
+ powered entirely by models <strong>under 32B parameters</strong>.
168
+ </div>
169
+ {project_section}
170
+ <div class="stats">
171
+ <div class="stat"><b>&lt; 32B</b><span>Params Β· Local-first</span></div>
172
+ <div class="stat"><b>$48K+</b><span>Prize Pool</span></div>
173
+ </div>
174
+ </div>
175
+
176
+ <div class="sponsors">
177
+ <div class="sponsors__t">Proudly supported by</div>
178
+ <div class="sponsors__l">
179
+ OpenBMB <i>Β·</i> Black Forest Labs <i>Β·</i> OpenAI <i>Β·</i> NVIDIA <i>Β·</i> Modal <i>Β·</i> JetBrains <i>Β·</i> Cohere Labs
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </body>
185
+ </html>
certificate_upload_module.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload generated certificates to the public Build Small gallery dataset."""
2
+ import os
3
+ import time
4
+ import tempfile
5
+ import logging
6
+
7
+ from PIL import Image as PILImage
8
+ from datasets import Dataset, Image, load_dataset, concatenate_datasets
9
+ from huggingface_hub import HfApi
10
+
11
+ logging.basicConfig(level=logging.INFO)
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # PUBLIC dataset that acts as the gallery of issued certificates
15
+ CERTIFICATE_DATASET_NAME = "build-small-hackathon/build-small-certificates"
16
+
17
+ HF_TOKEN = os.getenv("HF_TOKEN")
18
+
19
+
20
+ def safe_add_certificate_to_dataset(certificate_image, hf_username, max_retries=5, retry_delay=3):
21
+ """Append a certificate image to the dataset, handling empty/existing datasets and dedup."""
22
+ try:
23
+ if not hf_username or not hf_username.strip():
24
+ return False, "❌ Error: HF username is required"
25
+ if certificate_image is None:
26
+ return False, "❌ Error: Certificate image is required"
27
+ hf_username = hf_username.strip()
28
+ logger.info(f"Processing certificate for user: {hf_username}")
29
+
30
+ existing_dataset = None
31
+ load_successful = False
32
+ is_empty_dataset = False
33
+
34
+ for attempt in range(max_retries):
35
+ try:
36
+ existing_dataset = load_dataset(CERTIFICATE_DATASET_NAME, split="train", token=HF_TOKEN)
37
+ logger.info(f"Loaded {len(existing_dataset)} existing certificates")
38
+ load_successful = True
39
+ break
40
+ except Exception as load_error:
41
+ error_str = str(load_error).lower()
42
+ if "corresponds to no data" in error_str or "no data" in error_str or "doesn't exist" in error_str or "not found" in error_str:
43
+ logger.info("Dataset empty / not yet created β€” will create first entry")
44
+ is_empty_dataset = True
45
+ load_successful = True
46
+ existing_dataset = None
47
+ break
48
+ logger.warning(f"Attempt {attempt + 1} failed: {str(load_error)[:120]}")
49
+ if attempt < max_retries - 1:
50
+ time.sleep(retry_delay)
51
+
52
+ if not load_successful:
53
+ return False, ("❌ Certificate upload temporarily unavailable. Please try again in a few minutes.")
54
+
55
+ # Dedup by username (stored in the 'label' column)
56
+ if existing_dataset is not None:
57
+ if hf_username in existing_dataset["label"]:
58
+ return True, f"a certificate for '{hf_username}' already exists in the gallery."
59
+
60
+ with tempfile.TemporaryDirectory() as temp_dir:
61
+ if isinstance(certificate_image, PILImage.Image):
62
+ temp_image_path = os.path.join(temp_dir, f"certificate_{hf_username}_{int(time.time())}.png")
63
+ certificate_image.save(temp_image_path, "PNG")
64
+ elif isinstance(certificate_image, str) and os.path.exists(certificate_image):
65
+ temp_image_path = certificate_image
66
+ else:
67
+ return False, "❌ Error: Invalid image format provided"
68
+
69
+ new_dataset = Dataset.from_dict(
70
+ {"image": [temp_image_path], "label": [hf_username]}
71
+ ).cast_column("image", Image())
72
+
73
+ if existing_dataset is not None and not is_empty_dataset:
74
+ combined_dataset = concatenate_datasets([existing_dataset, new_dataset])
75
+ else:
76
+ combined_dataset = new_dataset
77
+
78
+ try:
79
+ combined_dataset.push_to_hub(CERTIFICATE_DATASET_NAME, private=False, token=HF_TOKEN)
80
+ logger.info(f"Saved certificate. Total now: {len(combined_dataset)}")
81
+ return True, f"βœ… saved to the gallery for {hf_username}."
82
+ except Exception as upload_error:
83
+ msg = str(upload_error).lower()
84
+ if any(i in msg for i in ("rate limit", "429", "too many requests")):
85
+ return False, "⏳ Upload busy due to high load β€” please try again in 10–15 minutes."
86
+ logger.error(f"Upload failed: {upload_error}")
87
+ return False, f"❌ Certificate upload failed: {str(upload_error)}"
88
+ except Exception as e:
89
+ logger.error(f"Unexpected error in certificate upload: {e}")
90
+ return False, f"❌ Certificate upload failed: {str(e)}"
91
+
92
+
93
+ def upload_user_certificate(certificate_image, hf_username):
94
+ """Public entry point β€” returns (success, message)."""
95
+ if not certificate_image:
96
+ return False, "❌ No certificate image provided"
97
+ if not hf_username or not hf_username.strip():
98
+ return False, "❌ HF username is required"
99
+ return safe_add_certificate_to_dataset(certificate_image, hf_username)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ datasets
2
+ pandas
3
+ gradio_client