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:
106
frontend.tsx
106
frontend.tsx
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user