From 5ce7cce1a3a132a7fa4a7ebb5ab0658e5496251c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?= Date: Sat, 30 May 2026 18:06:36 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(hackaton):=20p=C3=A1ginas=20y=20proxy?= =?UTF-8?q?=20del=20#1=20Hackat=C3=B3n=20NaN=20(frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Landing /hackaton con CTA dependiente de fase y sesión, + fechas del evento (7–13 jun · Demo Day domingo 14) vía i18n es/en. - Páginas /hackaton/{checkin,me,submission,projects,leaderboard} y componentes Preact (RegisterForm, CheckinForm, ReassignForm, SubmissionForm, VoteButton). - Proxy SSR /api/hackaton/[...path] hacia CLOUD_API_URL: preserva la cookie de sesión y bloquea admin/* desde el navegador. - CTA de login apunta al login real de NaN (Discord OAuth en cloud-api, /api/auth/discord); no existe página /login propia. - Tests vitest del proxy. npm run build en verde. Co-Authored-By: Claude Opus 4.8 --- i18n/en.json | 89 ++++++++++++++ i18n/es.json | 89 ++++++++++++++ src/components/hackaton/CheckinForm.tsx | 23 ++++ src/components/hackaton/ReassignForm.tsx | 50 ++++++++ src/components/hackaton/RegisterForm.tsx | 129 +++++++++++++++++++++ src/components/hackaton/SubmissionForm.tsx | 46 ++++++++ src/components/hackaton/VoteButton.tsx | 33 ++++++ src/lib/hackaton.ts | 30 +++++ src/pages/api/hackaton/[...path].ts | 50 ++++++++ src/pages/hackaton.astro | 85 ++++++++++++++ src/pages/hackaton/checkin.astro | 31 +++++ src/pages/hackaton/leaderboard.astro | 42 +++++++ src/pages/hackaton/me.astro | 49 ++++++++ src/pages/hackaton/projects.astro | 47 ++++++++ src/pages/hackaton/submission.astro | 37 ++++++ src/tests/api/hackaton.test.ts | 19 +++ 16 files changed, 849 insertions(+) create mode 100644 src/components/hackaton/CheckinForm.tsx create mode 100644 src/components/hackaton/ReassignForm.tsx create mode 100644 src/components/hackaton/RegisterForm.tsx create mode 100644 src/components/hackaton/SubmissionForm.tsx create mode 100644 src/components/hackaton/VoteButton.tsx create mode 100644 src/lib/hackaton.ts create mode 100644 src/pages/api/hackaton/[...path].ts create mode 100644 src/pages/hackaton.astro create mode 100644 src/pages/hackaton/checkin.astro create mode 100644 src/pages/hackaton/leaderboard.astro create mode 100644 src/pages/hackaton/me.astro create mode 100644 src/pages/hackaton/projects.astro create mode 100644 src/pages/hackaton/submission.astro create mode 100644 src/tests/api/hackaton.test.ts diff --git a/i18n/en.json b/i18n/en.json index 9ff67207..edc33d6e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -316,5 +316,94 @@ "errorServer": "Something went wrong. Try again in a moment.", "errorNetwork": "No connection. Try again.", "footer": "Cancel anytime." + }, + "hackaton": { + "pageTitle": "#1 NaN Hackathon", + "label": "// hackathon", + "title": "#1 NaN Hackathon", + "subtitle": "7 days to build with the cluster's open source models. Teams of 4, balanced. Prize: 1 month of NaN free.", + "dates": "June 7–13 · build week · Sunday 14th: Demo Day", + "backToLanding": "← Back", + "rules": "Allowed models: Qwen 3.6 or Gemma 4. The project must be open source. Free theme.", + "loginCta": "Sign in to NaN to register", + "full": "All 40 seats are full. We'll add you to the reserve list (pool).", + "form": { + "name": "Name", + "discord": "Discord username", + "discordHelp": "Required — to coordinate your team", + "specialty": "Specialty", + "level": "Level", + "specFrontend": "Frontend", + "specBackend": "Backend", + "specMlAiData": "ML / AI / Data", + "specDesignProduct": "Design / Product", + "specOther": "Other", + "levelJunior": "Junior", + "levelMid": "Mid", + "levelSenior": "Senior", + "submit": "Register", + "submitting": "Sending…", + "success": "Registration recorded! Check your status in My team.", + "successReserve": "You're on the reserve list. If a seat frees up, you'll get in by order.", + "errorDiscord": "The Discord username is required.", + "errorFull": "Registration full (40 + 10 reserves).", + "errorEligible": "Your NaN account is not eligible to register.", + "errorServer": "Something went wrong. Try again.", + "errorNetwork": "Network error. Try again." + }, + "checkin": { + "title": "Check-in", + "intro": "Confirm your attendance to secure your seat.", + "button": "Confirm attendance", + "submitting": "Confirming…", + "done": "Attendance confirmed ✓", + "closed": "The check-in window is not open." + }, + "me": { + "title": "My team", + "noTeam": "Teams haven't been announced yet.", + "teammates": "Teammates", + "rules": "Rules: Qwen 3.6 / Gemma 4 + open source.", + "submissionCta": "Edit submission", + "reassignCta": "Request reassignment" + }, + "submission": { + "title": "Team submission", + "fTitle": "Title", + "description": "Description", + "publicUrl": "Public URL", + "spaceUrl": "Space URL", + "repoUrl": "Repo (open source)", + "mediaUrl": "Media (optional)", + "submit": "Save submission", + "submitting": "Saving…", + "saved": "Submission saved. Checks executed.", + "checks": "Automatic checks", + "urlLive": "Public URL responds", + "inNanSpace": "Deployed in a NaN Space", + "repoPublic": "Public repo", + "notPrizeEligible": "No valid public repo: not eligible for prize." + }, + "projects": { + "title": "Projects", + "empty": "No projects published yet.", + "vote": "Vote", + "voted": "Voted ✓", + "voting": "Sending…", + "loginToVote": "Sign in to vote", + "selfVote": "You can't vote for your own team.", + "alreadyVoted": "You've already voted.", + "notEligible": "Your NaN account is not eligible to vote." + }, + "leaderboard": { + "title": "Leaderboard", + "hidden": "The leaderboard will be published when voting closes.", + "rank": "#", + "team": "Team", + "votes": "Votes", + "auto": "Auto", + "total": "Total", + "winner": "🏆 Winner" + } } } \ No newline at end of file diff --git a/i18n/es.json b/i18n/es.json index 6a74e095..f9a0e41d 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -316,5 +316,94 @@ "errorServer": "Algo falló. Vuelve a intentarlo en un momento.", "errorNetwork": "Sin conexión. Vuelve a intentarlo.", "footer": "Cancela cuando quieras." + }, + "hackaton": { + "pageTitle": "#1 Hackatón NaN", + "label": "// hackatón", + "title": "#1 Hackatón NaN", + "subtitle": "7 días para construir con modelos open source del cluster. Equipos de 4, balanceados. Premio: 1 mes gratis de NaN.", + "dates": "7–13 de junio · construcción · Domingo 14: Demo Day", + "backToLanding": "← Volver", + "rules": "Modelos permitidos: Qwen 3.6 o Gemma 4. El proyecto debe ser open source. Tema libre.", + "loginCta": "Inicia sesión en NaN para inscribirte", + "full": "Las 40 plazas están llenas. Te apuntamos a la lista de reservas (pool).", + "form": { + "name": "Nombre", + "discord": "Usuario de Discord", + "discordHelp": "Obligatorio — para coordinar tu equipo", + "specialty": "Especialidad", + "level": "Nivel", + "specFrontend": "Frontend", + "specBackend": "Backend", + "specMlAiData": "ML / AI / Data", + "specDesignProduct": "Diseño / Producto", + "specOther": "Otro", + "levelJunior": "Junior", + "levelMid": "Mid", + "levelSenior": "Senior", + "submit": "Inscribirme", + "submitting": "Enviando…", + "success": "¡Inscripción registrada! Revisa tu estado en Mi equipo.", + "successReserve": "Estás en la lista de reservas. Si se libera plaza, entrarás por orden.", + "errorDiscord": "El usuario de Discord es obligatorio.", + "errorFull": "Inscripción completa (40 + 10 reservas).", + "errorEligible": "Tu cuenta NaN no es elegible para inscribirse.", + "errorServer": "Algo falló. Inténtalo de nuevo.", + "errorNetwork": "Error de red. Inténtalo de nuevo." + }, + "checkin": { + "title": "Check-in", + "intro": "Confirma tu asistencia para asegurar tu plaza.", + "button": "Confirmar asistencia", + "submitting": "Confirmando…", + "done": "Asistencia confirmada ✓", + "closed": "La ventana de check-in no está abierta." + }, + "me": { + "title": "Mi equipo", + "noTeam": "Aún no se han anunciado los equipos.", + "teammates": "Compañeros", + "rules": "Reglas: Qwen 3.6 / Gemma 4 + open source.", + "submissionCta": "Editar submission", + "reassignCta": "Convocar reasignación" + }, + "submission": { + "title": "Submission del equipo", + "fTitle": "Título", + "description": "Descripción", + "publicUrl": "URL pública", + "spaceUrl": "URL del Space", + "repoUrl": "Repo (open source)", + "mediaUrl": "Media (opcional)", + "submit": "Guardar submission", + "submitting": "Guardando…", + "saved": "Submission guardada. Checks ejecutados.", + "checks": "Checks automáticos", + "urlLive": "URL pública responde", + "inNanSpace": "Desplegada en Space de NaN", + "repoPublic": "Repo público", + "notPrizeEligible": "Sin repo público válido: no elegible a premio." + }, + "projects": { + "title": "Proyectos", + "empty": "Aún no hay proyectos publicados.", + "vote": "Votar", + "voted": "Votado ✓", + "voting": "Enviando…", + "loginToVote": "Inicia sesión para votar", + "selfVote": "No puedes votar a tu propio equipo.", + "alreadyVoted": "Ya has votado.", + "notEligible": "Tu cuenta NaN no es elegible para votar." + }, + "leaderboard": { + "title": "Leaderboard", + "hidden": "El leaderboard se publicará al cerrar la votación.", + "rank": "#", + "team": "Equipo", + "votes": "Votos", + "auto": "Auto", + "total": "Total", + "winner": "🏆 Ganador" + } } } \ No newline at end of file diff --git a/src/components/hackaton/CheckinForm.tsx b/src/components/hackaton/CheckinForm.tsx new file mode 100644 index 00000000..08556ef1 --- /dev/null +++ b/src/components/hackaton/CheckinForm.tsx @@ -0,0 +1,23 @@ +import { useState } from 'preact/hooks'; + +export default function CheckinForm({ t }: { t: { button: string; submitting: string; done: string; closed: string } }) { + const [state, setState] = useState<'idle' | 'busy' | 'done' | 'closed' | 'error'>('idle'); + async function checkin() { + setState('busy'); + let resp: Response; + try { + resp = await fetch('/api/hackaton/checkin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + } catch { setState('error'); return; } + if (resp.ok) { setState('done'); return; } + if (resp.status === 400) { setState('closed'); return; } + setState('error'); + } + if (state === 'done') return

{t.done}

; + if (state === 'closed') return

{t.closed}

; + return ( + + ); +} diff --git a/src/components/hackaton/ReassignForm.tsx b/src/components/hackaton/ReassignForm.tsx new file mode 100644 index 00000000..70415469 --- /dev/null +++ b/src/components/hackaton/ReassignForm.tsx @@ -0,0 +1,50 @@ +import { useState } from 'preact/hooks'; + +interface Member { + id: string; + name: string; + discord_user?: string; +} + +export default function ReassignForm({ members, ctaLabel }: { members: Member[]; ctaLabel: string }) { + const [ghostId, setGhostId] = useState(''); + const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle'); + + async function submit() { + if (!ghostId) return; + setState('busy'); + try { + const resp = await fetch('/api/hackaton/reassign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ghost_id: ghostId }), + }); + setState(resp.ok ? 'done' : 'error'); + } catch { + setState('error'); + } + } + + if (state === 'done') { + return

Reasignación registrada.

; + } + + return ( +
+ + {state === 'error' && } + +
+ ); +} diff --git a/src/components/hackaton/RegisterForm.tsx b/src/components/hackaton/RegisterForm.tsx new file mode 100644 index 00000000..35f31f68 --- /dev/null +++ b/src/components/hackaton/RegisterForm.tsx @@ -0,0 +1,129 @@ +import { useState } from 'preact/hooks'; +import type { TargetedSubmitEvent } from 'preact'; + +interface FormStrings { + name: string; discord: string; discordHelp: string; + specialty: string; level: string; + specFrontend: string; specBackend: string; specMlAiData: string; + specDesignProduct: string; specOther: string; + levelJunior: string; levelMid: string; levelSenior: string; + submit: string; submitting: string; success: string; successReserve: string; + errorDiscord: string; errorFull: string; errorEligible: string; + errorServer: string; errorNetwork: string; +} + +type Status = + | { kind: 'idle' } + | { kind: 'submitting' } + | { kind: 'success'; reserve: boolean } + | { kind: 'error'; message: string }; + +export default function RegisterForm({ t }: { t: FormStrings }) { + const [name, setName] = useState(''); + const [discord, setDiscord] = useState(''); + const [specialty, setSpecialty] = useState(''); + const [level, setLevel] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + + async function onSubmit(e: TargetedSubmitEvent) { + e.preventDefault(); + if (discord.trim() === '') { + setStatus({ kind: 'error', message: t.errorDiscord }); + return; + } + setStatus({ kind: 'submitting' }); + + let resp: Response; + try { + resp = await fetch('/api/hackaton/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.trim(), + discord_user: discord.trim(), + specialty, + level, + }), + }); + } catch { + setStatus({ kind: 'error', message: t.errorNetwork }); + return; + } + + let body: any = null; + try { body = await resp.json(); } catch { body = null; } + + if (resp.status === 201 && body?.ok) { + setStatus({ kind: 'success', reserve: !!body?.data?.is_reserve }); + return; + } + let message = t.errorServer; + const code = body?.error ?? ''; + if (resp.status === 409 || code === 'registration_full') message = t.errorFull; + else if (resp.status === 403 || code === 'not_eligible') message = t.errorEligible; + else if (code === 'discord_required') message = t.errorDiscord; + setStatus({ kind: 'error', message }); + } + + if (status.kind === 'success') { + return ( +
+

+ {status.reserve ? t.successReserve : t.success} +

+ + → Mi equipo + +
+ ); + } + + const submitting = status.kind === 'submitting'; + const err = status.kind === 'error' ? status.message : null; + const inputCls = + 'w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors'; + const labelCls = 'block font-mono text-[10px] uppercase tracking-widest text-violet-400 mb-2'; + + return ( +
+
+ + setName((e.currentTarget as HTMLInputElement).value)} class={inputCls} /> +
+
+ + setDiscord((e.currentTarget as HTMLInputElement).value)} class={inputCls} /> +

{t.discordHelp}

+
+
+ + +
+
+ + +
+ {err && } + +
+ ); +} diff --git a/src/components/hackaton/SubmissionForm.tsx b/src/components/hackaton/SubmissionForm.tsx new file mode 100644 index 00000000..23d5265e --- /dev/null +++ b/src/components/hackaton/SubmissionForm.tsx @@ -0,0 +1,46 @@ +import { useState } from 'preact/hooks'; +import type { TargetedSubmitEvent } from 'preact'; + +export default function SubmissionForm({ t }: { t: Record }) { + const [f, setF] = useState({ title: '', description: '', public_url: '', space_url: '', repo_url: '', media_url: '' }); + const [checks, setChecks] = useState(null); + const [busy, setBusy] = useState(false); + const set = (k: string) => (e: any) => setF({ ...f, [k]: e.currentTarget.value }); + + async function onSubmit(e: TargetedSubmitEvent) { + e.preventDefault(); + setBusy(true); + const resp = await fetch('/api/hackaton/submission', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(f), + }); + const b = await resp.json().catch(() => null); + setBusy(false); + if (resp.ok && b?.ok) setChecks(b.data); + } + + const inputCls = 'w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono'; + return ( +
+ +