Servidor Fastify + Socket.IO + Drizzle/Postgres; front Vite+React (EN/ES, tema claro/oscuro). Tableros por columnas con plantillas, notas privadas/públicas, agrupación, votos configurables (multivoto), timer compartido con alarma, fases, action items, export Markdown/JSON e import, autodestrucción, gestión de columnas. Imagen única (front servido por el servidor) y CI que la construye y publica.
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
import { createContext, useContext, useState, type ReactNode } from "react";
|
|
|
|
export type Lang = "en" | "es";
|
|
|
|
const dict = {
|
|
en: {
|
|
"app.tagline": "Free retro boards. No signup.",
|
|
"home.title": "Create a retro board",
|
|
"home.newBoard": "New board",
|
|
"home.lead": "A ship makes a sternboard when it moves backward, stern first — looking back to move forward. That's a retro. (stern = back · board)",
|
|
"home.feat1": "No signup",
|
|
"home.feat2": "Shared timer, votes & grouping",
|
|
"home.feat3": "Retro step indicator (brainstorm → vote → discuss)",
|
|
"home.feat4": "Optional self-destruct on creation",
|
|
"home.feat5": "Public & private notes — publish when you want",
|
|
"home.custom": "Custom — define your columns",
|
|
"home.columns": "Columns",
|
|
"home.columnName": "Column name",
|
|
"home.addColumn": "+ Add column",
|
|
"home.boardTitle": "Board title",
|
|
"home.template": "Template",
|
|
"home.freeColumns": "Free columns (comma separated)",
|
|
"home.votesPerUser": "Votes per person",
|
|
"home.selfDestruct": "Self-destruct",
|
|
"home.never": "Never",
|
|
"home.hours": "{n}h",
|
|
"home.days": "{n}d",
|
|
"home.create": "Create board",
|
|
"home.creating": "Creating…",
|
|
"home.importTitle": "Import a board",
|
|
"home.importQuestion": "Have an export?",
|
|
"home.importJson": "Import JSON",
|
|
"home.importHint": "Load a .retro.json export",
|
|
"home.importMode": "What to import",
|
|
"home.importFull": "Everything (notes, votes, groups)",
|
|
"home.importStructure": "Only structure (columns + settings)",
|
|
"home.importBtn": "Choose file & import",
|
|
"board.exportJson": "Export JSON",
|
|
"join.title": "Enter your name to join",
|
|
"join.invitedTo": "You're invited to",
|
|
"join.yourName": "Your name",
|
|
"join.noPasswords": "Your name is visible to the team. No passwords.",
|
|
"join.namePlaceholder": "What's your name?",
|
|
"join.enter": "Enter board",
|
|
"join.nameRequired": "Name is required",
|
|
"state.createNew": "Create a new board",
|
|
"board.loading": "Loading…",
|
|
"board.share": "Copy invite link",
|
|
"board.copied": "Link copied",
|
|
"board.reveal": "Reveal all notes",
|
|
"board.hide": "Hide all notes",
|
|
"board.hidden": "hidden",
|
|
"board.revealOwn": "Reveal/hide my note",
|
|
"board.privateSection": "Private Section",
|
|
"board.publishAll": "Publish all",
|
|
"board.publish": "Publish",
|
|
"board.makePrivate": "Make private",
|
|
"board.typeHere": "Type here…\nPress Enter to save.",
|
|
"board.noCards": "No cards yet.",
|
|
"board.revealVotes": "Reveal votes",
|
|
"board.hideVotes": "Hide votes",
|
|
"board.settings": "Settings",
|
|
"board.votesWord": "votes",
|
|
"settings.modOnly": "Only moderators can change board settings.",
|
|
"board.addColumn": "Add column",
|
|
"phase.brainstorm": "Brainstorm",
|
|
"phase.voting": "Voting",
|
|
"phase.discuss": "Discuss",
|
|
"col.rename": "Rename",
|
|
"col.moveLeft": "Move left",
|
|
"col.moveRight": "Move right",
|
|
"col.delete": "Delete column",
|
|
"board.multiVote": "Multiple votes per item",
|
|
"board.groupName": "Group name…",
|
|
"board.ungroup": "Ungroup",
|
|
"board.addNote": "Add a note…",
|
|
"board.add": "Add",
|
|
"board.votesLeft": "{n} votes left",
|
|
"board.export": "Export Markdown",
|
|
"board.timer": "Timer",
|
|
"board.start": "Start",
|
|
"board.stop": "Stop",
|
|
"board.timeUp": "Time's up",
|
|
"board.actionItems": "Action items",
|
|
"board.addAction": "Add action item…",
|
|
"board.owner": "owner",
|
|
"board.expiresIn": "Self-destructs in {t}",
|
|
"board.destroyed": "This board was destroyed.",
|
|
"board.notFound": "Board not found.",
|
|
"board.moderator": "Moderator",
|
|
"common.delete": "Delete",
|
|
"common.save": "Save",
|
|
"common.cancel": "Cancel",
|
|
"common.on": "On",
|
|
"common.off": "Off",
|
|
"common.minutes": "min",
|
|
"toolbar.participants": "Participants",
|
|
"toolbar.actionPoints": "Action points",
|
|
"toolbar.export": "Export",
|
|
"toolbar.options": "Options",
|
|
"panel.participants": "Participants",
|
|
"panel.actionPoints": "Action Points",
|
|
"panel.people": "People",
|
|
"panel.you": "you",
|
|
"panel.showCreators": "Show card creators",
|
|
"panel.addActionPoint": "Add action point…",
|
|
"panel.todo": "To do",
|
|
"panel.done": "Done",
|
|
"export.title": "Export retrospective",
|
|
"export.options": "Export options",
|
|
"export.created": "Add creation date",
|
|
"export.exported": "Add export date",
|
|
"export.expandGroups": "Expand groups",
|
|
"export.authors": "Show author names",
|
|
"export.votes": "Show votes",
|
|
"export.preview": "Preview",
|
|
"export.copy": "Copy to clipboard",
|
|
"export.save": "Save as file",
|
|
"options.moderation": "Moderation",
|
|
"options.makeEveryone": "Everyone can moderate",
|
|
"options.makeSingle": "Only me can moderate",
|
|
"options.claim": "Claim retrospective",
|
|
"options.voting": "Voting",
|
|
"options.clearVotes": "Remove all votes",
|
|
"options.danger": "Danger zone",
|
|
"options.lock": "Lock (read-only)",
|
|
"options.unlock": "Unlock",
|
|
"options.remove": "Remove retrospective",
|
|
"options.removeConfirm": "Delete this retrospective for everyone?",
|
|
"board.lockedBanner": "🔒 Read-only (locked)",
|
|
},
|
|
es: {
|
|
"app.tagline": "Tableros de retro libres. Sin registro.",
|
|
"home.title": "Crea un tablero de retro",
|
|
"home.newBoard": "Nuevo tablero",
|
|
"home.lead": "Un barco hace un sternboard cuando avanza hacia atrás, popa primero — mirar atrás para avanzar. Eso es una retro. (stern = atrás · board = tablero)",
|
|
"home.feat1": "Sin registro",
|
|
"home.feat2": "Timer compartido, votos y agrupación",
|
|
"home.feat3": "Indicador de fase (lluvia → votación → discusión)",
|
|
"home.feat4": "Autodestrucción opcional al crearlo",
|
|
"home.feat5": "Notas públicas y privadas — publicas cuando quieres",
|
|
"home.custom": "Personalizado — define tus columnas",
|
|
"home.columns": "Columnas",
|
|
"home.columnName": "Nombre de la columna",
|
|
"home.addColumn": "+ Añadir columna",
|
|
"home.boardTitle": "Título del tablero",
|
|
"home.template": "Plantilla",
|
|
"home.freeColumns": "Columnas libres (separadas por comas)",
|
|
"home.votesPerUser": "Votos por persona",
|
|
"home.selfDestruct": "Autodestrucción",
|
|
"home.never": "Nunca",
|
|
"home.hours": "{n}h",
|
|
"home.days": "{n}d",
|
|
"home.create": "Crear tablero",
|
|
"home.creating": "Creando…",
|
|
"home.importTitle": "Importar un tablero",
|
|
"home.importQuestion": "¿Tienes un export?",
|
|
"home.importJson": "Importar JSON",
|
|
"home.importHint": "Carga un export .retro.json",
|
|
"home.importMode": "Qué importar",
|
|
"home.importFull": "Todo (notas, votos, grupos)",
|
|
"home.importStructure": "Solo estructura (columnas + ajustes)",
|
|
"home.importBtn": "Elegir archivo e importar",
|
|
"board.exportJson": "Exportar JSON",
|
|
"join.title": "Escribe tu nombre para entrar",
|
|
"join.invitedTo": "Te invitaron a",
|
|
"join.yourName": "Tu nombre",
|
|
"join.noPasswords": "Tu nombre será visible para el equipo. Sin contraseñas.",
|
|
"join.namePlaceholder": "¿Cómo te llamas?",
|
|
"join.enter": "Entrar al tablero",
|
|
"join.nameRequired": "El nombre es obligatorio",
|
|
"state.createNew": "Crear un tablero nuevo",
|
|
"board.loading": "Cargando…",
|
|
"board.share": "Copiar enlace de invitación",
|
|
"board.copied": "Enlace copiado",
|
|
"board.reveal": "Revelar todas",
|
|
"board.hide": "Ocultar todas",
|
|
"board.hidden": "oculta",
|
|
"board.revealOwn": "Revelar/ocultar mi nota",
|
|
"board.privateSection": "Sección privada",
|
|
"board.publishAll": "Publicar todas",
|
|
"board.publish": "Publicar",
|
|
"board.makePrivate": "Hacer privada",
|
|
"board.typeHere": "Escribe aquí…\nPulsa Enter para guardar.",
|
|
"board.noCards": "Aún no hay tarjetas.",
|
|
"board.revealVotes": "Revelar votos",
|
|
"board.hideVotes": "Ocultar votos",
|
|
"board.settings": "Ajustes",
|
|
"board.votesWord": "votos",
|
|
"settings.modOnly": "Solo los moderadores cambian los ajustes del tablero.",
|
|
"board.addColumn": "Añadir columna",
|
|
"phase.brainstorm": "Lluvia",
|
|
"phase.voting": "Votación",
|
|
"phase.discuss": "Discusión",
|
|
"col.rename": "Renombrar",
|
|
"col.moveLeft": "Mover ←",
|
|
"col.moveRight": "Mover →",
|
|
"col.delete": "Eliminar columna",
|
|
"board.multiVote": "Varios votos por ítem",
|
|
"board.groupName": "Nombre del grupo…",
|
|
"board.ungroup": "Deshacer grupo",
|
|
"board.addNote": "Añade una nota…",
|
|
"board.add": "Añadir",
|
|
"board.votesLeft": "{n} votos restantes",
|
|
"board.export": "Exportar Markdown",
|
|
"board.timer": "Temporizador",
|
|
"board.start": "Iniciar",
|
|
"board.stop": "Parar",
|
|
"board.timeUp": "Se acabó el tiempo",
|
|
"board.actionItems": "Conclusiones",
|
|
"board.addAction": "Añade una conclusión…",
|
|
"board.owner": "responsable",
|
|
"board.expiresIn": "Se autodestruye en {t}",
|
|
"board.destroyed": "Este tablero fue destruido.",
|
|
"board.notFound": "Tablero no encontrado.",
|
|
"board.moderator": "Moderador",
|
|
"common.delete": "Borrar",
|
|
"common.save": "Guardar",
|
|
"common.cancel": "Cancelar",
|
|
"common.on": "Sí",
|
|
"common.off": "No",
|
|
"common.minutes": "min",
|
|
"toolbar.participants": "Participantes",
|
|
"toolbar.actionPoints": "Conclusiones",
|
|
"toolbar.export": "Exportar",
|
|
"toolbar.options": "Opciones",
|
|
"panel.participants": "Participantes",
|
|
"panel.actionPoints": "Conclusiones",
|
|
"panel.people": "Personas",
|
|
"panel.you": "tú",
|
|
"panel.showCreators": "Mostrar autores de las tarjetas",
|
|
"panel.addActionPoint": "Añadir conclusión…",
|
|
"panel.todo": "Pendiente",
|
|
"panel.done": "Hecho",
|
|
"export.title": "Exportar retrospectiva",
|
|
"export.options": "Opciones de exportación",
|
|
"export.created": "Añadir fecha de creación",
|
|
"export.exported": "Añadir fecha de exportación",
|
|
"export.expandGroups": "Expandir grupos",
|
|
"export.authors": "Mostrar autores",
|
|
"export.votes": "Mostrar votos",
|
|
"export.preview": "Previsualización",
|
|
"export.copy": "Copiar al portapapeles",
|
|
"export.save": "Descargar archivo",
|
|
"options.moderation": "Moderación",
|
|
"options.makeEveryone": "Todos pueden moderar",
|
|
"options.makeSingle": "Solo yo modero",
|
|
"options.claim": "Reclamar retro",
|
|
"options.voting": "Votación",
|
|
"options.clearVotes": "Quitar todos los votos",
|
|
"options.danger": "Zona peligrosa",
|
|
"options.lock": "Bloquear (solo lectura)",
|
|
"options.unlock": "Desbloquear",
|
|
"options.remove": "Borrar retrospectiva",
|
|
"options.removeConfirm": "¿Borrar esta retrospectiva para todos?",
|
|
"board.lockedBanner": "🔒 Solo lectura (bloqueado)",
|
|
},
|
|
} as const;
|
|
|
|
type Key = keyof (typeof dict)["en"];
|
|
|
|
type I18nCtx = {
|
|
lang: Lang;
|
|
setLang: (l: Lang) => void;
|
|
t: (key: Key, vars?: Record<string, string | number>) => string;
|
|
};
|
|
|
|
const Ctx = createContext<I18nCtx | null>(null);
|
|
|
|
function detect(): Lang {
|
|
const saved = localStorage.getItem("lang");
|
|
if (saved === "en" || saved === "es") return saved;
|
|
return navigator.language.startsWith("es") ? "es" : "en";
|
|
}
|
|
|
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
|
const [lang, setLangState] = useState<Lang>(detect);
|
|
const setLang = (l: Lang) => {
|
|
localStorage.setItem("lang", l);
|
|
setLangState(l);
|
|
};
|
|
const t = (key: Key, vars?: Record<string, string | number>) => {
|
|
let s: string = dict[lang][key] ?? key;
|
|
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replace(`{${k}}`, String(v));
|
|
return s;
|
|
};
|
|
return <Ctx.Provider value={{ lang, setLang, t }}>{children}</Ctx.Provider>;
|
|
}
|
|
|
|
export function useI18n() {
|
|
const c = useContext(Ctx);
|
|
if (!c) throw new Error("useI18n fuera de I18nProvider");
|
|
return c;
|
|
}
|
|
|
|
export function LangSwitcher() {
|
|
const { lang, setLang } = useI18n();
|
|
return (
|
|
<div className="seg" role="group" aria-label="language">
|
|
<button className={lang === "es" ? "on" : ""} onClick={() => setLang("es")}>
|
|
ES
|
|
</button>
|
|
<button className={lang === "en" ? "on" : ""} onClick={() => setLang("en")}>
|
|
EN
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|