feat: add reader vote widget and vote-service
The skills-review desk is static, so "which draft would you ship?" needs a stateful counterpart. vote-service is a small Go API on its own pod backed by a JSON file on a ReadWriteOnce PVC, with one active vote per skill per source IP as the anti-abuse rule and CORS (ALLOWED_ORIGIN) as the caller boundary. Deployment notes that differ from the obvious path, all confirmed against the live cluster: the image is side-loaded with `ctr image import` plus `imagePullPolicy: Never` because kubelet has no credentials for the Nexus ref; the pod is pinned to `kubernets` because the hostpath PV takes a nodeAffinity for whichever node first binds it; and public exposure is Caddy on the VPS, not the cloudflared tunnel. The ingress controller runs with `use-forwarded-headers` off, so nginx overwrites X-Forwarded-For with its own peer — every visitor would collapse into one voter and each skill would cap at one vote overall. Caddy stamps the true remote address into X-Client-IP, which nginx forwards untouched, and clientIP() reads that first. Scoped to this app rather than flipping the global flag, which would change client-IP handling for every other ingress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { catalog } from './catalog.js';
|
||||
import { files } from './files.js';
|
||||
import { renderVoteWidget } from './vote.js';
|
||||
|
||||
const state = { selected: catalog[0], query: '', preview: 'original', file: null, sourceByPath: new Map(), lens: false, rendered: false, diff: false, searching: false, contentMatches: new Set(), searchTimer: null, searchRequest: 0 };
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -158,7 +159,8 @@ function previewMarkup(entry, available) {
|
||||
}
|
||||
function renderDetail() {
|
||||
const entry = state.selected; const available = packageFiles(entry);
|
||||
$('#detail').innerHTML = `<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${previewMarkup(entry, available)}`;
|
||||
$('#detail').innerHTML = `<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div id="vote-widget"></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${previewMarkup(entry, available)}`;
|
||||
renderVoteWidget($('#vote-widget'), entry.id);
|
||||
$('#detail').querySelectorAll('[data-file]').forEach((button) => button.addEventListener('click', () => { state.file = available.find((item) => item.name === button.dataset.file) || available[0]; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); loadSelectedFile(); }));
|
||||
$('#detail').querySelectorAll('[data-preview]').forEach((button) => button.addEventListener('click', () => { state.preview = button.dataset.preview; state.lens = false; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); loadSelectedFile(); }));
|
||||
$('#detail').querySelectorAll('[data-lens]').forEach((button) => button.addEventListener('click', () => { state.lens = !state.lens; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); }));
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
<meta name="description" content="Friendly reviews and improved drafts for submitted Agent Skills." />
|
||||
<title>Submitted Skills — Review Desk</title>
|
||||
<!-- Bump all review asset versions together when this interface changes. -->
|
||||
<link rel="stylesheet" href="styles.css?v=20260904-preview-toolbar" />
|
||||
<link rel="stylesheet" href="change-lens.css?v=20260904-preview-toolbar" />
|
||||
<link rel="stylesheet" href="styles.css?v=20260904-vote-widget" />
|
||||
<link rel="stylesheet" href="change-lens.css?v=20260904-vote-widget" />
|
||||
<!-- Vote API origin: set after vote-service is deployed (see /vote-service). Empty = widget shows "voting offline". -->
|
||||
<script>window.SKILLS_REVIEW_VOTE_API = 'https://ai-for-dummies-vote.marcospaulo.dev.br';</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
@@ -25,7 +27,7 @@
|
||||
</section>
|
||||
<section class="method">
|
||||
<div><p class="eyebrow">How to use this desk</p><h2>Compare.<br><em>Then choose.</em></h2></div>
|
||||
<ol><li>Select a submission, or open an author URL.</li><li>Read the gentle review before judging the draft.</li><li>Choose <strong>Preview Markdown</strong> in the file toolbar to render either version.</li><li>Copy or download the version you want.</li></ol>
|
||||
<ol><li>Select a submission, or open an author URL.</li><li>Read the gentle review before judging the draft.</li><li>Choose <strong>Preview Markdown</strong> in the file toolbar to render either version.</li><li>Copy or download the version you want, then vote for the draft you would ship.</li></ol>
|
||||
</section>
|
||||
<section class="catalog" id="catalog">
|
||||
<aside><p class="eyebrow">The catalog</p><label for="skill-filter">Find a skill</label><input id="skill-filter" type="search" placeholder="author, skill, topic" autocomplete="off"><p class="count" id="count"></p><div id="skill-list" role="listbox" aria-label="Submitted skills"></div></aside>
|
||||
@@ -36,8 +38,8 @@
|
||||
<p>The recommendations follow the open Agent Skills format: valid frontmatter for discovery, progressive disclosure for context economy, deterministic scripts for fragile repeated mechanics, and behavioral evaluation rather than a checklist of pretty headings.</p>
|
||||
<div><a href="https://agentskills.io/specification" target="_blank" rel="noreferrer">Format specification ↗</a><a href="https://agentskills.io/skill-creation/best-practices" target="_blank" rel="noreferrer">Writing practices ↗</a><a href="https://agentskills.io/skill-creation/evaluating-skills" target="_blank" rel="noreferrer">Evaluation loop ↗</a><a href="https://agentskills.io/skill-creation/using-scripts" target="_blank" rel="noreferrer">Scripts guide ↗</a></div>
|
||||
</section>
|
||||
<footer>Share an author with <code>?author=Name</code>, or one review with <code>?author=Name&skill=skill-id&view=improved</code>. To add a submission later: drop a package under <code>submitted-skills/</code>, add a tailored entry in <code>skills-review/catalog.js</code>, then run <code>node scripts/build-skill-review.mjs</code>.</footer>
|
||||
<footer>Share an author with <code>?author=Name</code>, or one review with <code>?author=Name&skill=skill-id&view=improved</code>. To add a submission later: drop a package under <code>submitted-skills/</code>, add a tailored entry in <code>skills-review/catalog.js</code>, then run <code>node scripts/build-skill-review.mjs</code>. Votes call a separate service — see <code>vote-service/</code> — one per visitor, tracked by network source.</footer>
|
||||
</main>
|
||||
<script type="module" src="app.js?v=20260904-preview-toolbar"></script>
|
||||
<script type="module" src="app.js?v=20260904-vote-widget"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
||||
// Reader vote widget: "which draft would you ship?" per reviewed skill.
|
||||
// The page itself is static (Gitea Pages), so this talks to a small
|
||||
// separate API — see /vote-service in the repository root. One vote per
|
||||
// source is enforced server-side by IP, not here; this module only renders
|
||||
// state and remembers the local choice so a returning visitor sees it
|
||||
// without re-voting.
|
||||
const API_BASE = (window.SKILLS_REVIEW_VOTE_API || '').replace(/\/$/, '');
|
||||
const escape = (value) => value.replace(/[&<>"']/g, (character) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[character]);
|
||||
|
||||
function voterId() {
|
||||
let id = localStorage.getItem('skills-review-voter-id');
|
||||
if (!id) { id = crypto.randomUUID(); localStorage.setItem('skills-review-voter-id', id); }
|
||||
return id;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { 'Content-Type': 'application/json', 'X-Voter-Id': voterId(), ...options.headers } });
|
||||
if (!response.ok) throw new Error(`vote API ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function widgetMarkup(skillId, tally, you, unavailable) {
|
||||
const total = (tally.original || 0) + (tally.improved || 0);
|
||||
const share = (count) => total ? Math.round((count / total) * 100) : 0;
|
||||
if (unavailable) return `<section class="vote-widget" aria-label="Vote unavailable"><span>READER VOTE</span><p>Voting is offline right now — the vote service is not configured or unreachable.</p></section>`;
|
||||
return `<section class="vote-widget" aria-label="Vote on this review" data-skill="${escape(skillId)}">
|
||||
<span>WHICH DRAFT WOULD YOU SHIP?</span>
|
||||
<div class="vote-buttons" role="group" aria-label="Cast your vote">
|
||||
<button data-vote="original" aria-pressed="${you === 'original'}">Original<b>${tally.original || 0} · ${share(tally.original || 0)}%</b></button>
|
||||
<button data-vote="improved" aria-pressed="${you === 'improved'}">Improved draft<b>${tally.improved || 0} · ${share(tally.improved || 0)}%</b></button>
|
||||
</div>
|
||||
<p class="vote-note">${you ? `You voted ${you === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.` : 'One vote per visitor, tracked by network source.'}</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export async function renderVoteWidget(container, skillId) {
|
||||
if (!API_BASE) { container.innerHTML = widgetMarkup(skillId, {}, null, true); return; }
|
||||
container.innerHTML = widgetMarkup(skillId, {}, null, false);
|
||||
const cast = async (choice) => {
|
||||
container.innerHTML = widgetMarkup(skillId, {}, null, false);
|
||||
try {
|
||||
const result = await api('/api/votes', { method: 'POST', body: JSON.stringify({ skillId, choice }) });
|
||||
container.innerHTML = widgetMarkup(skillId, { original: result.original, improved: result.improved }, result.you, false);
|
||||
bind();
|
||||
} catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); }
|
||||
};
|
||||
function bind() { container.querySelectorAll('[data-vote]').forEach((button) => button.addEventListener('click', () => cast(button.dataset.vote))); }
|
||||
try {
|
||||
const result = await api(`/api/votes?skillId=${encodeURIComponent(skillId)}`);
|
||||
container.innerHTML = widgetMarkup(skillId, result.tallies?.[skillId] || {}, result.you, false);
|
||||
} catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); }
|
||||
bind();
|
||||
}
|
||||
Reference in New Issue
Block a user