CodeSoft's picture
Add poll edit/delete for members; preserve votes for unchanged options
3bcd30f verified
Raw
History Blame Contribute Delete
14 kB
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;