File size: 20,924 Bytes
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d4818f
794a9f2
 
 
 
 
 
 
 
 
 
0eacc6f
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d4818f
 
 
 
 
794a9f2
 
5d4818f
ec3f75d
5d4818f
ec3f75d
 
 
 
 
 
 
 
 
 
 
 
 
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0eacc6f
 
4f20f8e
0eacc6f
794a9f2
 
 
0eacc6f
 
 
794a9f2
0eacc6f
 
5f5b418
4f20f8e
0eacc6f
794a9f2
 
 
 
 
 
0eacc6f
794a9f2
 
 
 
0eacc6f
4f20f8e
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
5f5b418
0eacc6f
 
 
 
 
 
 
 
5f5b418
 
 
 
 
 
 
0eacc6f
4f20f8e
0eacc6f
 
4f20f8e
0eacc6f
794a9f2
 
 
 
0eacc6f
5f5b418
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0eacc6f
794a9f2
4f20f8e
794a9f2
5f5b418
794a9f2
 
 
 
 
 
 
5f5b418
794a9f2
5f5b418
0eacc6f
5f5b418
0eacc6f
 
 
 
 
 
5f5b418
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f20f8e
794a9f2
4f20f8e
794a9f2
5f5b418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9831a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794a9f2
c9831a3
 
 
 
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f20f8e
c9831a3
 
4f20f8e
c9831a3
2974b38
 
 
 
794a9f2
 
 
 
 
 
 
 
 
 
 
 
c59f759
794a9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f5b418
 
5d4818f
 
 
 
 
794a9f2
5d4818f
794a9f2
 
 
5d4818f
 
794a9f2
0eacc6f
5f5b418
4f20f8e
 
0eacc6f
794a9f2
 
 
 
c59f759
2974b38
794a9f2
 
 
 
 
 
 
 
 
0eacc6f
5f5b418
794a9f2
 
 
 
0eacc6f
 
794a9f2
 
 
 
 
 
 
 
c59f759
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
"""
Build Small Hackathon β€” Certificate Generator
=============================================
Workflow:
  1. User signs in with Hugging Face (OAuth).
  2. We take their HF username and look it up in the private eligibility dataset
     `build-small-hackathon/build-small-apps-for-certificates`
     (registered participants βˆͺ org Space contributors).
       β€’ username found WITH a space  -> autofill full name + project name
       β€’ username found WITHOUT a space -> autofill full name, no project
       β€’ username not found             -> show contact message, do not proceed
  3. All autofilled fields are editable.
  4. A button generates the certificate (HTML -> PNG via the renderer Space) and
     saves it to the public gallery dataset `build-small-hackathon/build-small-certificates`.
"""
import os
import uuid
import inspect
import tempfile
import urllib.parse
from functools import lru_cache

import gradio as gr
import pandas as pd
from PIL import Image
from datasets import load_dataset
from gradio_client import Client, handle_file

from certificate_upload_module import upload_user_certificate, get_certificate_image_path

HF_TOKEN = os.getenv("HF_TOKEN")

ELIGIBILITY_DATASET = "build-small-hackathon/build-small-apps-for-certificates"
RENDERER_SPACE = "https://ysharma-hackathon-certificate-html-to-image.hf.space/"

DISCORD_INVITE = "https://discord.gg/92sEPT2Zhv"
CONTACT_EMAIL = "hello@gradio.app"

_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "certificate_template.html")
with open(_TEMPLATE_PATH, encoding="utf-8") as _f:
    CERTIFICATE_HTML_TEMPLATE = _f.read()

PROJECT_SECTION_WITH_NAME = '<div class="project"><span class="lbl">Project</span> {project_name}</div>'
PROJECT_SECTION_EMPTY = ""


# ----------------------------------------------------------------------------- renderer client
_gradio_client = None


def get_gradio_client():
    """Lazy init of the HTML->image renderer client (avoids event-loop issues).

    The auth kwarg for gradio_client.Client changed across versions
    (`hf_token` vs `token`), so pick whichever the installed version exposes.
    """
    global _gradio_client
    if _gradio_client is None:
        params = inspect.signature(Client.__init__).parameters
        attempts = []
        if "hf_token" in params:
            attempts.append({"hf_token": HF_TOKEN})
        if "token" in params:
            attempts.append({"token": HF_TOKEN})
        attempts.append({"headers": {"Authorization": f"Bearer {HF_TOKEN}"}})
        last_err = None
        for kwargs in attempts:
            try:
                _gradio_client = Client(RENDERER_SPACE, **kwargs)
                break
            except TypeError as e:
                last_err = e
        if _gradio_client is None:
            raise last_err
    return _gradio_client


# ----------------------------------------------------------------------------- eligibility lookup
@lru_cache(maxsize=1)
def _eligibility_index():
    """Load the eligibility dataset once into {username_lower: (full_name, space_name)}."""
    ds = load_dataset(ELIGIBILITY_DATASET, split="train", token=HF_TOKEN)
    df = ds.to_pandas().fillna("")
    index = {}
    for _, row in df.iterrows():
        uname = str(row.get("hf_username", "")).strip()
        if not uname:
            continue
        index[uname.lower()] = (
            str(row.get("full_name", "")).strip(),
            str(row.get("space_name", "")).strip(),
        )
    return index


def lookup_participant(username: str):
    """Return (state, full_name, space_name).

    state ∈ {"found_project", "found_no_project", "not_found"}.
    """
    if not username:
        return "not_found", "", ""
    try:
        index = _eligibility_index()
    except Exception as e:
        print(f"[ERROR] eligibility load failed: {e}")
        return "error", "", ""

    entry = index.get(username.strip().lower())
    if entry is None:
        return "not_found", "", ""
    full_name, space_name = entry
    if space_name:
        return "found_project", full_name, space_name
    return "found_no_project", full_name, ""


# ----------------------------------------------------------------------------- on login
def on_load(profile: gr.OAuthProfile | None):
    """Runs on page load. Drives which panel is shown, pre-fills the form, and β€” if the user
    already generated a certificate β€” shows it and reveals the 'Recreate' control."""
    reset_cb = gr.update(value=False)   # never toggle the checkbox's own visibility (keeps it interactive)

    if profile is None:
        return (
            "Please sign in with your Hugging Face account to continue.",
            gr.update(visible=False),           # main_interface
            gr.update(visible=False),           # contact_box
            "",                                  # name
            gr.update(value="", visible=True),  # project
            "",                                  # data_status
            None,                                # certificate_image
            None,                                # certificate_file
            reset_cb,                            # recreate_checkbox
            "",                                  # generation_status
        )

    username = profile.username
    profile_name = profile.name or username
    state, full_name, space_name = lookup_participant(username)

    if state in ("not_found", "error"):
        return (
            f"Signed in as **{username}**.",
            gr.update(visible=False),
            gr.update(visible=True),
            "", gr.update(value="", visible=True), "",
            None, None, reset_cb, "",
        )

    name_value = full_name or profile_name
    if state == "found_project":
        status = f"βœ… Found your submission β€” **{space_name}**. Review the details below, then generate."
        project_update = gr.update(value=space_name, visible=True)
    else:  # found_no_project
        status = (
            "βœ… You're on the participant list! We couldn't find a Space submission under your "
            "name β€” you can add a project below or leave it blank."
        )
        project_update = gr.update(value="", visible=True)

    # Already generated a certificate before? Fetch and show it, with guidance up top.
    existing = None
    try:
        existing = get_certificate_image_path(username)
    except Exception:
        existing = None

    if existing:
        gen_status = (
            "### πŸ“œ You already have a certificate\n"
            "It's shown below β€” **download it** and use it however you like (LinkedIn, socials, etc.).\n\n"
            "- Want a version with **different details**? Edit your name/project and click "
            "**Generate my certificate** β€” that's just a fresh preview to download and **won't change** "
            "the copy saved in our records.\n"
            "- Want to **replace** your saved copy? Tick **Recreate certificate** below, then generate. "
            "There's one certificate per participant, so the new one overwrites the old."
        )
        cb = reset_cb
    else:
        gen_status = ""
        cb = reset_cb

    return (
        f"Signed in as **{username}**.",
        gr.update(visible=True),
        gr.update(visible=False),
        name_value, project_update, status,
        existing, existing, cb, gen_status,
    )


# ----------------------------------------------------------------------------- LinkedIn helper
def generate_linkedin_url(participant_name, project_name):
    params = {
        "startTask": "CERTIFICATION_NAME",
        "name": "Build Small Hackathon 2026",
        "organizationName": "Hugging Face",
        "issueYear": "2026",
        "issueMonth": "6",
    }
    return "https://www.linkedin.com/profile/add?" + urllib.parse.urlencode(
        params, quote_via=urllib.parse.quote
    )


# ----------------------------------------------------------------------------- generate
def create_certificate(participant_name, project_name, recreate,
                       oauth_token: gr.OAuthToken | None, profile: gr.OAuthProfile | None):
    hide_cb = gr.update(value=False)
    if profile is None:
        return None, None, "❌ Please sign in first to generate your certificate.", "", hide_cb

    username = profile.username
    state, _, _ = lookup_participant(username)
    if state == "not_found":
        return None, None, (
            "❌ We couldn't find you in the Build Small Hackathon records, so we can't issue a "
            f"certificate. Please reach out on [Discord]({DISCORD_INVITE}) or email {CONTACT_EMAIL}."
        ), "", hide_cb
    if state == "error":
        return None, None, "❌ Couldn't reach the hackathon records right now. Please try again shortly.", "", hide_cb

    # Does a saved certificate already exist for this user?
    existing_path = None
    try:
        existing_path = get_certificate_image_path(username)
    except Exception:
        existing_path = None

    # Always render a fresh certificate from the CURRENT field values.
    participant_name = (participant_name or "").strip() or (profile.name or username)
    project_name = (project_name or "").strip()
    project_section = (
        PROJECT_SECTION_WITH_NAME.replace("{project_name}", project_name)
        if project_name else PROJECT_SECTION_EMPTY
    )
    html = CERTIFICATE_HTML_TEMPLATE.replace("{participant_name}", participant_name)
    html = html.replace("{project_section}", project_section)
    with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as f:
        f.write(html)
        html_path = f.name

    try:
        client = get_gradio_client()
        image_path = client.predict(html_file=handle_file(html_path), api_name="/predict")[0]
    except Exception as e:
        return None, None, f"❌ Error generating certificate image: {e}", "", gr.update(value=False)

    show_cb = gr.update(value=False)
    linkedin_url = generate_linkedin_url(participant_name, project_name)

    # Decide what happens to the SAVED copy in the dataset.
    if existing_path is None:
        # First time β€” save it.
        try:
            ok, msg = upload_user_certificate(Image.open(image_path), username, overwrite=False)
            note = ("πŸŽ‰ Your certificate is ready and has been **saved to our records**. Download it "
                    "below β€” you can edit your details and regenerate anytime.") if ok else \
                   f"⚠️ Certificate generated, but saving hit an issue: {msg}"
        except Exception as e:
            note = f"⚠️ Certificate generated, but saving was skipped: {e}"
        return image_path, image_path, note, linkedin_url, show_cb

    if recreate:
        # Replace the saved copy.
        try:
            ok, msg = upload_user_certificate(Image.open(image_path), username, overwrite=True)
            note = ("πŸ” **Done β€” your new certificate has replaced the previous one saved in our "
                    "records.** Download the updated copy below.") if ok else \
                   f"⚠️ Generated, but replacing the saved copy hit an issue: {msg}"
        except Exception as e:
            note = f"⚠️ Generated, but replacing the saved copy was skipped: {e}"
        return image_path, image_path, note, linkedin_url, show_cb

    # Existing copy + recreate NOT ticked β†’ fresh preview only, dataset untouched.
    note = (
        "πŸ‘€ **Here's a new preview with your latest details** β€” download it below. "
        "Your **saved** certificate in our records is **unchanged**. "
        "To make this version the official one, tick **Recreate certificate** above and generate again."
    )
    return image_path, image_path, note, linkedin_url, show_cb


def render_linkedin_button(url):
    if not url:
        return "<div style='padding:.5rem;color:var(--ink-soft)'>Generate your certificate to enable LinkedIn sharing.</div>"
    return f"""
    <a href="{url}" target="_blank" class="bs-linkedin">Add to LinkedIn profile β†’</a>
    <p style="margin-top:8px;font-size:.85rem;color:#6b6655">
      Opens LinkedIn with the certification pre-filled β€” then upload your downloaded image.</p>
    """


# ----------------------------------------------------------------------------- theming
CUSTOM_CSS = """
@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');
:root{
  --paper:#f4eee1; --kraft:#e4d5b7; --kraft-deep:#d6c19a; --line:#cdbb95;
  --ink:#33312b; --ink-soft:rgba(51,49,43,.62); --forest:#3d6a55; --forest-ink:#20392d; --amber:#e0913a;
}
/* Theme-proof: force the light "paper" look for ALL visitors, even on Gradio dark mode.
   We override Gradio's theme CSS variables for both :root and .dark so blocks, inputs,
   labels and text never go dark-on-dark or light-on-light. */
.gradio-container, .dark, body, body.dark{
  color-scheme: light !important;
  --body-background-fill:#f4eee1 !important;
  --background-fill-primary:#ffffff !important;
  --background-fill-secondary:#efe7d4 !important;
  --block-background-fill:#ffffff !important;
  --block-label-background-fill:#efe7d4 !important;
  --panel-background-fill:#efe7d4 !important;
  --block-label-text-color:#33312b !important;
  --block-title-text-color:#33312b !important;
  --block-info-text-color:rgba(51,49,43,.62) !important;
  --body-text-color:#33312b !important;
  --body-text-color-subdued:rgba(51,49,43,.60) !important;
  --input-background-fill:#ffffff !important;
  --input-text-color:#33312b !important;
  --input-placeholder-color:rgba(51,49,43,.45) !important;
  --border-color-primary:#33312b !important;
  --checkbox-background-color:#ffffff !important;
  --checkbox-background-color-selected:#3d6a55 !important;
  --checkbox-border-color:#33312b !important;
  --link-text-color:#3d6a55 !important;
}
.gradio-container{max-width:880px !important;margin:auto !important;
  font-family:'Archivo',system-ui,sans-serif !important;background:var(--paper) !important;color:var(--ink) !important;}
.gradio-container .prose, .gradio-container .prose *, .gradio-container label,
.gradio-container p, .gradio-container span, .gradio-container .md{color:var(--ink) !important;}
.gradio-container .prose strong{color:var(--ink) !important;}
.bs-hero{position:relative;overflow:hidden;background:var(--kraft);border:2px solid var(--ink);
  box-shadow:6px 6px 0 var(--ink);padding:30px 34px;margin-bottom:22px;}
.bs-hero .kick{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.24em;
  text-transform:uppercase;color:var(--forest);font-weight:600;}
.bs-hero h1{font-family:'Archivo';font-weight:900;font-stretch:120%;font-size:46px;line-height:.95;
  letter-spacing:-.02em;color:var(--ink);margin:8px 0 6px;}
.bs-hero p{color:#5a5446;font-size:15px;margin:0;}
.bs-section{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.18em;
  text-transform:uppercase;color:var(--ink-soft);font-weight:600;margin:6px 0 2px;display:flex;
  align-items:center;gap:9px;}
.bs-section::before{content:"";width:20px;height:2px;background:var(--amber);display:inline-block;}
button.primary, .bs-generate button{background:var(--forest) !important;border:2px solid var(--forest-ink) !important;
  color:#fff !important;border-radius:0 !important;font-family:'Archivo' !important;font-weight:800 !important;
  box-shadow:4px 4px 0 var(--forest-ink) !important;transition:transform .1s,box-shadow .1s !important;}
.bs-generate button:hover{transform:translate(2px,2px) !important;box-shadow:2px 2px 0 var(--forest-ink) !important;}
.bs-contact{background:#fbeee0;border:2px solid var(--amber);box-shadow:4px 4px 0 var(--ink);padding:18px 22px;}
.bs-contact a{color:var(--forest);font-weight:700;}
.bs-linkedin{display:inline-block;background:var(--ink);color:var(--paper) !important;text-decoration:none;
  font-weight:800;padding:11px 20px;border:2px solid var(--ink);box-shadow:3px 3px 0 var(--amber);}
input:not([type='checkbox']):not([type='radio']), textarea{border-radius:0 !important;
  border:2px solid var(--ink) !important;background:#fff !important;color:var(--ink) !important;
  -webkit-text-fill-color:var(--ink) !important;
  font-family:'Spline Sans Mono',monospace !important;}
input::placeholder, textarea::placeholder{color:rgba(51,49,43,.45) !important;-webkit-text-fill-color:rgba(51,49,43,.45) !important;}
/* Download (PNG) file row β€” force light bg + readable ink text in any theme */
#cert-file, #cert-file *{background-color:#fff !important;color:var(--ink) !important;
  -webkit-text-fill-color:var(--ink) !important;border-color:var(--ink) !important;fill:var(--ink) !important;}
#cert-file a, #cert-file a *{color:var(--forest) !important;-webkit-text-fill-color:var(--forest) !important;}
footer{display:none !important;}
"""

BS_THEME = gr.themes.Base(
    primary_hue=gr.themes.colors.green,
    secondary_hue=gr.themes.colors.orange,
    neutral_hue=gr.themes.colors.stone,
    font=gr.themes.GoogleFont("Archivo"),
).set(button_large_radius="0px", button_small_radius="0px", block_radius="0px")


# ----------------------------------------------------------------------------- UI
with gr.Blocks(title="Build Small β€” Certificate Generator") as demo:
    gr.HTML(
        """
        <div class="bs-hero">
          <div class="kick">Hugging Face Γ— Gradio Β· 2026</div>
          <h1>Build Small β€” Certificate</h1>
          <p>Sign in with Hugging Face to claim your certificate of participation. Built something
             small, local, and yours? Let's make it official.</p>
        </div>
        """
    )

    with gr.Group():
        login_btn = gr.LoginButton(value="Sign in with Hugging Face")
        login_status = gr.Markdown("Please sign in with your Hugging Face account to continue.")

    contact_box = gr.HTML(
        f"""
        <div class="bs-contact">
          <strong>We couldn't find you in the Build Small Hackathon records.</strong><br>
          Certificates are issued to registered participants and Build Small org Space contributors.
          If you think this is a mistake, reach out on
          <a href="{DISCORD_INVITE}" target="_blank">Discord</a> or email
          <a href="mailto:{CONTACT_EMAIL}">{CONTACT_EMAIL}</a>.
        </div>
        """,
        visible=False,
    )

    with gr.Column(visible=False) as main_interface:
        gr.HTML('<div class="bs-section">Your details</div>')
        data_status = gr.Markdown("")
        # status / "you already have a certificate" notice β€” kept at the TOP, above the form
        generation_status = gr.Markdown("")
        gr.Markdown(
            "✏️ **These details were auto-filled from the Build Small Hackathon records β€” "
            "you can edit both your name and your project/Space name** below before generating "
            "your certificate."
        )
        participant_name = gr.Textbox(
            label="Full name (editable)",
            info="This appears on your certificate β€” edit if you'd like it shown differently.",
        )
        project_name = gr.Textbox(
            label="Project / Space name (editable, optional)",
            info="Auto-filled from your Build Small submission. Edit it, or leave blank for a certificate without a project.",
        )
        recreate_checkbox = gr.Checkbox(
            label="Recreate certificate β€” replace the copy saved in our records",
            info="Only matters once you already have a saved certificate β€” tick it to overwrite the stored copy.",
            value=False, visible=True, interactive=True,
        )
        with gr.Row(elem_classes=["bs-generate"]):
            generate_btn = gr.Button("Generate my certificate", variant="primary", size="lg")

        gr.HTML('<div class="bs-section">Your certificate</div>')
        certificate_image = gr.Image(label="Preview", type="filepath", interactive=False)
        certificate_file = gr.File(label="Download (PNG)", interactive=False, elem_id="cert-file")

        gr.HTML('<div class="bs-section">Share</div>')
        linkedin_html = gr.HTML(render_linkedin_button(""))

    linkedin_state = gr.State("")

    demo.load(
        fn=on_load,
        inputs=None,
        outputs=[login_status, main_interface, contact_box, participant_name, project_name,
                 data_status, certificate_image, certificate_file, recreate_checkbox, generation_status],
    )

    generate_btn.click(
        fn=create_certificate,
        inputs=[participant_name, project_name, recreate_checkbox],
        outputs=[certificate_image, certificate_file, generation_status, linkedin_state, recreate_checkbox],
    ).then(
        fn=lambda url: render_linkedin_button(url),
        inputs=[linkedin_state],
        outputs=[linkedin_html],
    )


if __name__ == "__main__":
    demo.launch(theme=BS_THEME, css=CUSTOM_CSS)