feat: convert to argument.es — Spanish, vote buttons, Docker

- Translate all ~430 prompts to Spanish with cultural adaptations
- Translate all UI strings (frontend, admin, history, broadcast)
- Translate AI system prompts; models now respond in Spanish
- Replace Twitch/Fossabot viewer voting with in-site vote buttons
- Add POST /api/vote endpoint (IP-based, supports vote switching)
- Vote buttons appear during voting phase with active state highlight
- Rename project to argument.es throughout (package.json, cookie, DB)
- Add docker-compose.yml with SQLite volume mount
- Add .env.sample documenting all required and optional vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 13:09:00 +01:00
parent ccaa86b4a6
commit 2abea42c18
16 changed files with 1124 additions and 1150 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import "./frontend.css";
@@ -120,7 +120,7 @@ function PromptCard({ round }: { round: RoundState }) {
return (
<div className="prompt">
<div className="prompt__by">
<ModelTag model={round.prompter} small /> is writing a prompt
<ModelTag model={round.prompter} small /> está escribiendo una pregunta
<Dots />
</div>
<div className="prompt__text prompt__text--loading">
@@ -134,7 +134,7 @@ function PromptCard({ round }: { round: RoundState }) {
return (
<div className="prompt">
<div className="prompt__text prompt__text--error">
Prompt generation failed
Error al generar la pregunta
</div>
</div>
);
@@ -143,7 +143,7 @@ function PromptCard({ round }: { round: RoundState }) {
return (
<div className="prompt">
<div className="prompt__by">
Prompted by <ModelTag model={round.prompter} small />
Pregunta de <ModelTag model={round.prompter} small />
</div>
<div className="prompt__text">{round.prompt}</div>
</div>
@@ -185,7 +185,7 @@ function ContestantCard({
>
<div className="contestant__head">
<ModelTag model={task.model} />
{isWinner && <span className="win-tag">WIN</span>}
{isWinner && <span className="win-tag">GANA</span>}
</div>
<div className="contestant__body">
@@ -213,7 +213,7 @@ function ContestantCard({
{voteCount}
</span>
<span className="vote-meta__label">
vote{voteCount !== 1 ? "s" : ""}
voto{voteCount !== 1 ? "s" : ""}
</span>
<span className="vote-meta__dots">
{voters.map((v, i) => {
@@ -252,7 +252,7 @@ function ContestantCard({
{viewerVotes ?? 0}
</span>
<span className="vote-meta__label">
viewer vote{(viewerVotes ?? 0) !== 1 ? "s" : ""}
voto{(viewerVotes ?? 0) !== 1 ? "s" : ""} del público
</span>
<span className="viewer-vote-meta__icon">👥</span>
</div>
@@ -270,10 +270,14 @@ function Arena({
round,
total,
viewerVotingSecondsLeft,
myVote,
onVote,
}: {
round: RoundState;
total: number | null;
viewerVotingSecondsLeft: number;
myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void;
}) {
const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done";
@@ -294,12 +298,12 @@ function Arena({
const phaseText =
round.phase === "prompting"
? "Writing prompt"
? "Generando pregunta"
: round.phase === "answering"
? "Answering"
? "Respondiendo"
: round.phase === "voting"
? "Judges voting"
: "Complete";
? "Votando los jueces"
: "Completado";
return (
<div className="arena">
@@ -316,8 +320,22 @@ function Arena({
</span>
</div>
{showCountdown && (
<div className="vote-hint">
Vote in Twitch chat: <strong>1</strong> for left, <strong>2</strong> for right.
<div className="vote-panel">
<span className="vote-panel__label">¿Cuál es más gracioso?</span>
<div className="vote-panel__buttons">
<button
className={`vote-btn ${myVote === "A" ? "vote-btn--active" : ""}`}
onClick={() => onVote("A")}
>
<ModelTag model={contA} />
</button>
<button
className={`vote-btn ${myVote === "B" ? "vote-btn--active" : ""}`}
onClick={() => onVote("B")}
>
<ModelTag model={contB} />
</button>
</div>
</div>
)}
@@ -349,7 +367,7 @@ function Arena({
)}
{isDone && votesA === votesB && totalVotes > 0 && (
<div className="tie-label">Tie</div>
<div className="tie-label">Empate</div>
)}
</div>
);
@@ -363,7 +381,7 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
return (
<div className="game-over">
<div className="game-over__label">Game Over</div>
<div className="game-over__label">Fin del Juego</div>
{champion && champion[1] > 0 && (
<div className="game-over__winner">
<span className="game-over__crown">👑</span>
@@ -374,7 +392,7 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
{getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />}
{champion[0]}
</span>
<span className="game-over__sub">is the funniest AI</span>
<span className="game-over__sub">es la IA más graciosa</span>
</div>
)}
</div>
@@ -450,26 +468,26 @@ function Standings({
return (
<aside className="standings">
<div className="standings__head">
<span className="standings__title">Standings</span>
<span className="standings__title">Clasificación</span>
<div className="standings__links">
<a href="/history" className="standings__link">
History
Historial
</a>
<a href="https://twitch.tv/quipslop" target="_blank" rel="noopener noreferrer" className="standings__link">
<a href="https://twitch.tv/argument_es" target="_blank" rel="noopener noreferrer" className="standings__link">
Twitch
</a>
<a href="https://github.com/T3-Content/quipslop" target="_blank" rel="noopener noreferrer" className="standings__link">
GitHub
<a href="https://argument.es" target="_blank" rel="noopener noreferrer" className="standings__link">
Web
</a>
</div>
</div>
<LeaderboardSection
label="AI Judges"
label="Jueces IA"
scores={scores}
competing={competing}
/>
<LeaderboardSection
label="Viewers"
label="Público"
scores={viewerScores}
competing={competing}
/>
@@ -483,10 +501,10 @@ function ConnectingScreen() {
return (
<div className="connecting">
<div className="connecting__logo">
<img src="/assets/logo.svg" alt="quipslop" />
<img src="/assets/logo.svg" alt="argument.es" />
</div>
<div className="connecting__sub">
Connecting
Conectando
<Dots />
</div>
</div>
@@ -501,6 +519,8 @@ function App() {
const [viewerCount, setViewerCount] = useState(0);
const [connected, setConnected] = useState(false);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const lastVotedRoundRef = useRef<number | null>(null);
// Countdown timer for viewer voting
useEffect(() => {
@@ -519,6 +539,28 @@ function App() {
return () => clearInterval(interval);
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
// Reset my vote when a new round starts
useEffect(() => {
const roundNum = state?.active?.num ?? null;
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
setMyVote(null);
lastVotedRoundRef.current = roundNum;
}
}, [state?.active?.num]);
async function handleVote(side: "A" | "B") {
setMyVote(side);
try {
await fetch("/api/vote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side }),
});
} catch {
// ignore network errors
}
}
useEffect(() => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
@@ -569,7 +611,7 @@ function App() {
<main className="main">
<header className="header">
<a href="/" className="logo">
<img src="/assets/logo.svg" alt="quipslop" />
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{state.isPaused && (
@@ -577,12 +619,12 @@ function App() {
className="viewer-pill"
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
>
Paused
En pausa
</div>
)}
<div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" />
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
{viewerCount} espectador{viewerCount === 1 ? "" : "es"} conectado{viewerCount === 1 ? "" : "s"}
</div>
</div>
</header>
@@ -594,18 +636,20 @@ function App() {
round={displayRound}
total={totalRounds}
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myVote={myVote}
onVote={handleVote}
/>
) : (
<div className="waiting">
Starting
Iniciando
<Dots />
</div>
)}
{isNextPrompting && state.lastCompleted && (
<div className="next-toast">
<ModelTag model={state.active!.prompter} small /> is writing the
next prompt
<ModelTag model={state.active!.prompter} small /> está escribiendo
la siguiente pregunta
<Dots />
</div>
)}