Spaces:
Sleeping
Sleeping
| import React, { useCallback, useEffect, useRef, useState } from 'react'; | |
| import { Client } from '@gradio/client'; | |
| interface Me { | |
| username: string | null; | |
| member: boolean; | |
| } | |
| interface Poll { | |
| id: string; | |
| question: string; | |
| options: string[]; | |
| counts: number[]; | |
| total: number; | |
| created_by: string; | |
| created_at: string; | |
| my_vote: number | null; | |
| voters: Record<string, number>; | |
| } | |
| type Notice = { kind: 'ok' | 'err'; text: string } | null; | |
| type Tab = 'polls' | 'create'; | |
| async function callApi<T>(client: unknown, name: string, args: unknown[]): Promise<T> { | |
| const c = client as { predict: (n: string, a: unknown[]) => Promise<{ data: unknown[] }> }; | |
| const res = await c.predict(name, args); | |
| return res.data[0] as T; | |
| } | |
| const PollApp: React.FC = () => { | |
| const clientRef = useRef<unknown>(null); | |
| const [ready, setReady] = useState(false); | |
| const [backendError, setBackendError] = useState<string | null>(null); | |
| const [me, setMe] = useState<Me>({ username: null, member: false }); | |
| const [polls, setPolls] = useState<Poll[]>([]); | |
| const [busy, setBusy] = useState(false); | |
| const [tab, setTab] = useState<Tab>('polls'); | |
| const [editingId, setEditingId] = useState<string | null>(null); | |
| const [question, setQuestion] = useState(''); | |
| const [options, setOptions] = useState<string[]>(['', '']); | |
| const [notice, setNotice] = useState<Notice>(null); | |
| const MAX_OPTIONS = 20; | |
| const setOption = (idx: number, value: string) => { | |
| setOptions((prev) => prev.map((o, i) => (i === idx ? value : o))); | |
| }; | |
| const addOption = () => { | |
| setOptions((prev) => (prev.length >= MAX_OPTIONS ? prev : [...prev, ''])); | |
| }; | |
| const removeOption = (idx: number) => { | |
| setOptions((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx))); | |
| }; | |
| const refresh = useCallback(async () => { | |
| const client = clientRef.current; | |
| if (!client) return; | |
| try { | |
| const data = await callApi<{ me: Me; polls: Poll[] }>(client, '/polls', []); | |
| setMe(data.me); | |
| setPolls(data.polls); | |
| setBackendError(null); | |
| } catch { | |
| setBackendError('Cannot reach the voting backend.'); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| let cancelled = false; | |
| (async () => { | |
| try { | |
| clientRef.current = await Client.connect(window.location.origin + '/gradio'); | |
| if (!cancelled) { | |
| setReady(true); | |
| await refresh(); | |
| } | |
| } catch { | |
| if (!cancelled) setBackendError('Cannot reach the voting backend.'); | |
| } | |
| })(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [refresh]); | |
| const doVote = async (pollId: string, idx: number) => { | |
| if (busy || !clientRef.current) return; | |
| setBusy(true); | |
| try { | |
| const data = await callApi<{ ok: boolean; message: string; poll: Poll | null }>( | |
| clientRef.current, | |
| '/vote', | |
| [pollId, idx] | |
| ); | |
| setNotice({ kind: data.ok ? 'ok' : 'err', text: data.message }); | |
| if (data.ok && data.poll) { | |
| setPolls((prev) => prev.map((p) => (p.id === data.poll!.id ? data.poll! : p))); | |
| } else { | |
| await refresh(); | |
| } | |
| } catch { | |
| setNotice({ kind: 'err', text: 'Vote failed — please retry.' }); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }; | |
| const startEdit = (poll: Poll) => { | |
| setEditingId(poll.id); | |
| setQuestion(poll.question); | |
| setOptions(poll.options.length > 0 ? [...poll.options] : ['', '']); | |
| setNotice(null); | |
| setTab('create'); | |
| }; | |
| const cancelEdit = () => { | |
| setEditingId(null); | |
| setQuestion(''); | |
| setOptions(['', '']); | |
| setNotice(null); | |
| }; | |
| const doSubmit = async () => { | |
| if (busy || !clientRef.current) return; | |
| setBusy(true); | |
| const optsArg = options.filter((o) => o.trim()).join('\n'); | |
| const api = editingId ? '/edit_poll' : '/create_poll'; | |
| const args = editingId ? [editingId, question, optsArg] : [question, optsArg]; | |
| try { | |
| const data = await callApi<{ ok: boolean; message: string; poll: Poll | null }>( | |
| clientRef.current, | |
| api, | |
| args | |
| ); | |
| setNotice({ kind: data.ok ? 'ok' : 'err', text: data.message }); | |
| if (data.ok) { | |
| setEditingId(null); | |
| setQuestion(''); | |
| setOptions(['', '']); | |
| await refresh(); | |
| setTab('polls'); | |
| } | |
| } catch { | |
| setNotice({ kind: 'err', text: 'Could not save poll — please retry.' }); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }; | |
| const doDelete = async (poll: Poll) => { | |
| if (busy || !clientRef.current) return; | |
| if (!window.confirm(`Delete poll “${poll.question}” and its ${poll.total} vote(s)?`)) return; | |
| setBusy(true); | |
| try { | |
| const data = await callApi<{ ok: boolean; message: string }>( | |
| clientRef.current, | |
| '/delete_poll', | |
| [poll.id] | |
| ); | |
| setNotice({ kind: data.ok ? 'ok' : 'err', text: data.message }); | |
| await refresh(); | |
| } catch { | |
| setNotice({ kind: 'err', text: 'Could not delete poll — please retry.' }); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }; | |
| return ( | |
| <div className="page"> | |
| <div className="bg" aria-hidden="true"> | |
| <div className="bg-orb bg-orb-a" /> | |
| <div className="bg-orb bg-orb-b" /> | |
| <div className="bg-orb bg-orb-c" /> | |
| <div className="bg-grid" /> | |
| </div> | |
| <div className="shell"> | |
| <header className="top"> | |
| <h1> | |
| <span className="logo" aria-hidden="true">🗳️</span> SLM Consortium Polls | |
| </h1> | |
| <div className="auth"> | |
| {me.username ? ( | |
| <span className={`chip ${me.member ? 'chip-ok' : 'chip-warn'}`}> | |
| @{me.username} | |
| {me.member ? '' : ' · not a member'} | |
| </span> | |
| ) : ( | |
| <a | |
| className="btn" | |
| href="/gradio/login/huggingface?_target_url=/" | |
| > | |
| Sign in with Hugging Face | |
| </a> | |
| )} | |
| <button className="btn quiet" onClick={() => void refresh()}> | |
| Refresh | |
| </button> | |
| </div> | |
| </header> | |
| <p className="lede"> | |
| Hugging Face sign-in required. Only <code>slmconsortium</code> members can vote or create | |
| polls. Click to vote, and click another option to change your vote. | |
| </p> | |
| <nav className="tabs" role="tablist" aria-label="Sections"> | |
| <button | |
| role="tab" | |
| aria-selected={tab === 'polls'} | |
| className={tab === 'polls' ? 'active' : ''} | |
| onClick={() => setTab('polls')} | |
| > | |
| 🗳️ Polls | |
| </button> | |
| <button | |
| role="tab" | |
| aria-selected={tab === 'create'} | |
| className={tab === 'create' ? 'active' : ''} | |
| onClick={() => setTab('create')} | |
| > | |
| ➕ Create poll | |
| </button> | |
| </nav> | |
| {backendError && <p className="notice err">{backendError}</p>} | |
| {notice && <p className={`notice ${notice.kind}`}>{notice.text}</p>} | |
| {!ready && !backendError && <p className="muted">Loading…</p>} | |
| {tab === 'polls' && ( | |
| <div className="tabpanel" role="tabpanel"> | |
| {ready && polls.length === 0 && <p className="muted">No polls yet.</p>} | |
| {polls.map((poll) => ( | |
| <section key={poll.id} className="card"> | |
| <h2>{poll.question}</h2> | |
| <p className="meta"> | |
| {poll.total} vote{poll.total === 1 ? '' : 's'} · by @{poll.created_by} | |
| {me.member && ( | |
| <span className="card-actions"> | |
| <button | |
| className="icon-btn" | |
| disabled={busy} | |
| title="Edit poll (votes for edited/removed options are dropped)" | |
| aria-label={`Edit poll ${poll.question}`} | |
| onClick={() => startEdit(poll)} | |
| > | |
| ✏️ | |
| </button> | |
| <button | |
| className="icon-btn danger" | |
| disabled={busy} | |
| title="Delete poll" | |
| aria-label={`Delete poll ${poll.question}`} | |
| onClick={() => void doDelete(poll)} | |
| > | |
| 🗑️ | |
| </button> | |
| </span> | |
| )} | |
| </p> | |
| <ul className="opts"> | |
| {poll.options.map((opt, idx) => { | |
| const pct = poll.total ? (poll.counts[idx] / poll.total) * 100 : 0; | |
| const mine = poll.my_vote === idx; | |
| const voters = Object.entries(poll.voters ?? {}) | |
| .filter(([, v]) => v === idx) | |
| .map(([u]) => u) | |
| .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); | |
| return ( | |
| <li key={idx}> | |
| <button | |
| className={mine ? 'mine' : ''} | |
| disabled={!me.member || busy} | |
| title={ | |
| !me.username | |
| ? 'Sign in to vote' | |
| : !me.member | |
| ? 'Only slmconsortium members can vote' | |
| : `Vote for ${opt}` | |
| } | |
| onClick={() => void doVote(poll.id, idx)} | |
| > | |
| <span className="opt-top"> | |
| <span> | |
| {opt} | |
| {mine ? ' ✓' : ''} | |
| </span> | |
| <span className="num"> | |
| {poll.counts[idx]} · {pct.toFixed(0)}% | |
| </span> | |
| </span> | |
| <span className="bar"> | |
| <i style={{ width: `${pct}%` }} /> | |
| </span> | |
| {voters.length > 0 && ( | |
| <span className="voters"> | |
| {voters.map((u) => ( | |
| <span key={u} className={`voter ${u === me.username ? 'me' : ''}`}> | |
| @{u} | |
| </span> | |
| ))} | |
| </span> | |
| )} | |
| </button> | |
| </li> | |
| ); | |
| })} | |
| </ul> | |
| </section> | |
| ))} | |
| </div> | |
| )} | |
| {tab === 'create' && ( | |
| <div className="tabpanel" role="tabpanel"> | |
| <section className="card"> | |
| <h2>{editingId ? 'Edit poll' : 'New poll'}</h2> | |
| {!me.member && ( | |
| <p className="muted"> | |
| {!me.username ? 'Sign in first.' : 'Only slmconsortium members can create polls.'} | |
| </p> | |
| )} | |
| <label> | |
| Question | |
| <input | |
| value={question} | |
| onChange={(e) => setQuestion(e.target.value)} | |
| placeholder="Which meeting time works best?" | |
| disabled={!me.member} | |
| /> | |
| </label> | |
| <div className="field"> | |
| <span className="field-label"> | |
| Options | |
| <span className="field-hint"> | |
| {options.length}/{MAX_OPTIONS} | |
| </span> | |
| </span> | |
| <ul className="opt-editor"> | |
| {options.map((opt, idx) => ( | |
| <li key={idx}> | |
| <input | |
| value={opt} | |
| onChange={(e) => setOption(idx, e.target.value)} | |
| placeholder={`Option ${idx + 1}`} | |
| disabled={!me.member} | |
| aria-label={`Option ${idx + 1}`} | |
| /> | |
| <button | |
| type="button" | |
| className="icon-btn" | |
| onClick={() => removeOption(idx)} | |
| disabled={!me.member || options.length <= 1} | |
| aria-label={`Remove option ${idx + 1}`} | |
| title={options.length <= 1 ? 'Need at least one option row' : 'Remove option'} | |
| > | |
| ✕ | |
| </button> | |
| </li> | |
| ))} | |
| </ul> | |
| <button | |
| type="button" | |
| className="btn quiet add-opt" | |
| onClick={addOption} | |
| disabled={!me.member || options.length >= MAX_OPTIONS} | |
| > | |
| + Add option | |
| </button> | |
| </div> | |
| <div className="form-actions"> | |
| <button | |
| className="btn" | |
| disabled={ | |
| !me.member || | |
| busy || | |
| question.trim().length < 3 || | |
| options.filter((o) => o.trim()).length < 2 | |
| } | |
| onClick={() => void doSubmit()} | |
| > | |
| {busy ? 'Working…' : editingId ? 'Save changes' : 'Create poll'} | |
| </button> | |
| {editingId && ( | |
| <button className="btn quiet" onClick={cancelEdit} disabled={busy}> | |
| Cancel | |
| </button> | |
| )} | |
| </div> | |
| </section> | |
| </div> | |
| )} | |
| <footer className="muted"> | |
| <a href="/gradio/">Classic UI</a> | |
| </footer> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default PollApp; | |