Sternboard: free retro board, no signup (initial)
CI/CD Pipeline / test (push) Successful in 26s
CI/CD Pipeline / build-and-push (push) Successful in 44s

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.
This commit is contained in:
Millaguie
2026-06-16 10:27:33 +02:00
commit f11ea58c2c
64 changed files with 12359 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
import { useState } from "react";
import { Drawer } from "./Drawer";
import { useI18n } from "../i18n";
import type { BoardState } from "../types";
import type { BoardActions } from "../useBoard";
export function ActionPointsPanel({
state,
actions,
onClose,
}: {
state: BoardState;
actions: BoardActions;
onClose: () => void;
}) {
const { t } = useI18n();
const [text, setText] = useState("");
const todo = state.actionItems.filter((a) => !a.done);
const done = state.actionItems.filter((a) => a.done);
function add() {
if (!text.trim()) return;
actions.addAction(text.trim());
setText("");
}
return (
<Drawer title={t("panel.actionPoints")} onClose={onClose}>
<div className="add-action">
<input
data-testid="action-input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder={t("panel.addActionPoint")}
/>
<button data-testid="action-add" onClick={add}>
</button>
</div>
<h3 className="panel-section">{t("panel.todo")} ({todo.length})</h3>
<ul className="action-list" data-testid="todo-list">
{todo.map((a) => (
<ActionRow key={a.id} item={a} actions={actions} />
))}
</ul>
<h3 className="panel-section">{t("panel.done")} ({done.length})</h3>
<ul className="action-list done" data-testid="done-list">
{done.map((a) => (
<ActionRow key={a.id} item={a} actions={actions} />
))}
</ul>
</Drawer>
);
}
function ActionRow({
item,
actions,
}: {
item: BoardState["actionItems"][number];
actions: BoardActions;
}) {
const { t } = useI18n();
return (
<li className={`action-row${item.done ? " is-done" : ""}`} data-testid="action-row">
<input
type="checkbox"
data-testid="action-done"
checked={item.done}
onChange={(e) => actions.toggleAction(item.id, e.target.checked)}
/>
<input
className="action-text"
defaultValue={item.text}
onBlur={(e) => e.target.value !== item.text && actions.updateAction(item.id, { text: e.target.value })}
/>
<input
className="owner"
placeholder={t("board.owner")}
defaultValue={item.owner ?? ""}
onBlur={(e) => e.target.value !== (item.owner ?? "") && actions.updateAction(item.id, { owner: e.target.value })}
/>
<button className="del" onClick={() => actions.deleteAction(item.id)}>
</button>
</li>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { LangSwitcher } from "../i18n";
import { ThemeSwitcher } from "../theme";
// Marca "S" + wordmark a la izquierda; tema e idioma a la derecha.
export function AppBar({ children }: { children?: React.ReactNode }) {
return (
<header className="appbar">
<a className="brand" href="/" style={{ textDecoration: "none" }}>
<span className="brand-logo">S</span>
<span className="brand-name">sternboard</span>
</a>
<div className="appbar-right">
{children}
<ThemeSwitcher />
<LangSwitcher />
</div>
</header>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
export function Drawer({
title,
onClose,
children,
}: {
title: string;
onClose: () => void;
children: ReactNode;
}) {
return (
<div className="drawer-backdrop" onClick={onClose}>
<aside className="drawer" onClick={(e) => e.stopPropagation()}>
<div className="drawer-head">
<h2>{title}</h2>
<button className="icon" onClick={onClose} aria-label="close">
</button>
</div>
<div className="drawer-body">{children}</div>
</aside>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
type Opts = {
created: boolean;
exported: boolean;
groups: boolean;
authors: boolean;
votes: boolean;
};
export function ExportModal({ boardId, onClose }: { boardId: string; onClose: () => void }) {
const { t } = useI18n();
const [opts, setOpts] = useState<Opts>({
created: true,
exported: false,
groups: false,
authors: true,
votes: true,
});
const [preview, setPreview] = useState("");
const [copied, setCopied] = useState(false);
const query = (download = false) => {
const p = new URLSearchParams({
created: opts.created ? "1" : "0",
exported: opts.exported ? "1" : "0",
groups: opts.groups ? "1" : "0",
authors: opts.authors ? "1" : "0",
votes: opts.votes ? "1" : "0",
});
if (download) p.set("download", "1");
return `/api/boards/${boardId}/export?${p.toString()}`;
};
useEffect(() => {
let live = true;
fetch(query())
.then((r) => r.text())
.then((txt) => live && setPreview(txt))
.catch(() => {});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opts]);
const toggle = (k: keyof Opts) => setOpts((o) => ({ ...o, [k]: !o[k] }));
const Row = ({ k, label }: { k: keyof Opts; label: string }) => (
<label className="toggle-row">
<input data-testid={`opt-${k}`} type="checkbox" checked={opts[k]} onChange={() => toggle(k)} />
{label}
</label>
);
async function copy() {
await navigator.clipboard.writeText(preview);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="card modal export-modal" onClick={(e) => e.stopPropagation()}>
<div className="drawer-head">
<h2>{t("export.title")}</h2>
<button className="icon" onClick={onClose}>
</button>
</div>
<div className="export-grid">
<div className="export-opts">
<h3 className="panel-section">{t("export.options")}</h3>
<Row k="created" label={t("export.created")} />
<Row k="exported" label={t("export.exported")} />
<Row k="groups" label={t("export.expandGroups")} />
<Row k="authors" label={t("export.authors")} />
<Row k="votes" label={t("export.votes")} />
</div>
<div className="export-preview">
<h3 className="panel-section">{t("export.preview")}</h3>
<textarea data-testid="export-preview" readOnly value={preview} />
</div>
</div>
<div className="modal-actions">
<button className="link" data-testid="export-copy" onClick={copy}>
{copied ? t("board.copied") : t("export.copy")}
</button>
<a className="btn primary" data-testid="export-save" href={query(true)}>
{t("export.save")}
</a>
</div>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from "react";
import { Drawer } from "./Drawer";
import { useI18n } from "../i18n";
import type { BoardState } from "../types";
import type { BoardActions } from "../useBoard";
export function ParticipantsPanel({
state,
participantId,
isModerator,
actions,
onClose,
}: {
state: BoardState;
participantId: string;
isModerator: boolean;
actions: BoardActions;
onClose: () => void;
}) {
const { t } = useI18n();
return (
<Drawer title={t("panel.participants")} onClose={onClose}>
<h3 className="panel-section">{t("board.settings")}</h3>
{isModerator && (
<label className="toggle-row">
<input
data-testid="show-creators"
type="checkbox"
checked={state.showCardCreators}
onChange={(e) => actions.updateBoard({ showCardCreators: e.target.checked })}
/>
{t("panel.showCreators")}
</label>
)}
<h3 className="panel-section">
{t("panel.people")} ({state.participants.length})
</h3>
<ul className="people">
{state.participants.map((p) => (
<li key={p.id}>
{p.id === participantId ? (
<EditableName name={p.name} onSave={actions.renameMe} youLabel={t("panel.you")} />
) : (
<span>{p.name}</span>
)}
</li>
))}
</ul>
</Drawer>
);
}
function EditableName({
name,
onSave,
youLabel,
}: {
name: string;
onSave: (n: string) => void;
youLabel: string;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(name);
if (!editing)
return (
<span className="me">
<button className="icon" data-testid="edit-name" onClick={() => setEditing(true)} title="✎">
</button>
{name} <em>({youLabel})</em>
</span>
);
return (
<span className="me">
<input
data-testid="name-input"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && value.trim() && (onSave(value.trim()), setEditing(false))}
/>
<button
data-testid="name-save"
onClick={() => value.trim() && (onSave(value.trim()), setEditing(false))}
>
</button>
</span>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useState } from "react";
import { Drawer } from "./Drawer";
import { useI18n } from "../i18n";
import type { BoardState } from "../types";
import type { BoardActions } from "../useBoard";
// Panel unificado: Ajustes + Opciones + Export (lo que antes estaba disperso).
export function SettingsPanel({
state,
boardId,
isModerator,
actions,
onOpenExport,
onClose,
}: {
state: BoardState;
boardId: string;
isModerator: boolean;
actions: BoardActions;
onOpenExport: () => void;
onClose: () => void;
}) {
const { t } = useI18n();
const [title, setTitle] = useState(state.title);
const [votes, setVotes] = useState(state.votesPerUser);
const everyone = state.moderationMode === "everyone";
function saveBoard() {
actions.updateBoard({ title: title.trim() || state.title, votesPerUser: votes });
onClose();
}
return (
<Drawer title={t("board.settings")} onClose={onClose}>
{/* Export — disponible para todos */}
<h3 className="panel-section">{t("toolbar.export")}</h3>
<div className="toggle-row" style={{ gap: 10 }}>
<button data-testid="open-export" onClick={onOpenExport}>
{t("export.title")}
</button>
<a className="btn" data-testid="export-json" href={`/api/boards/${boardId}/export.json`}>
JSON
</a>
</div>
{!isModerator && <p className="muted-note">{t("settings.modOnly")}</p>}
{isModerator && (
<>
{/* Ajustes del tablero */}
<h3 className="panel-section">{t("board.settings")}</h3>
<label className="field-label">{t("home.boardTitle")}</label>
<input data-testid="settings-title" value={title} onChange={(e) => setTitle(e.target.value)} />
<label className="field-label" style={{ marginTop: 14 }}>
{t("home.votesPerUser")}
</label>
<input
data-testid="settings-votes"
type="number"
min={0}
max={50}
value={votes}
onChange={(e) => setVotes(Number(e.target.value))}
style={{ maxWidth: 110 }}
/>
<label className="toggle-row" style={{ marginTop: 10 }}>
<input
data-testid="settings-multivote"
type="checkbox"
checked={state.allowMultiVote}
onChange={(e) => actions.updateBoard({ allowMultiVote: e.target.checked })}
/>
{t("board.multiVote")}
</label>
<div style={{ marginTop: 14 }}>
<button data-testid="settings-save" className="primary" onClick={saveBoard}>
{t("common.save")}
</button>
</div>
{/* Revelado */}
<h3 className="panel-section">{t("board.reveal")}</h3>
<div className="toggle-row" style={{ gap: 10 }}>
<button data-testid="reveal-all" onClick={() => actions.reveal(!state.revealed)}>
{state.revealed ? t("board.hide") : t("board.reveal")}
</button>
</div>
{/* Moderación */}
<h3 className="panel-section">{t("options.moderation")}</h3>
<div className="toggle-row" style={{ gap: 10, flexWrap: "wrap" }}>
<button
data-testid="opt-moderation-toggle"
onClick={() => actions.updateBoard({ moderationMode: everyone ? "single" : "everyone" })}
>
{everyone ? t("options.makeSingle") : t("options.makeEveryone")}
</button>
<button data-testid="opt-claim" onClick={() => actions.claim()}>
{t("options.claim")}
</button>
</div>
{/* Votación */}
<h3 className="panel-section">{t("options.voting")}</h3>
<button data-testid="opt-clear-votes" onClick={() => actions.clearVotes()}>
{t("options.clearVotes")}
</button>
{/* Zona peligrosa */}
<h3 className="panel-section danger" style={{ color: "var(--danger-text)" }}>
{t("options.danger")}
</h3>
<div className="toggle-row" style={{ gap: 10, flexWrap: "wrap" }}>
<button data-testid="opt-lock" onClick={() => actions.updateBoard({ locked: !state.locked })}>
{state.locked ? t("options.unlock") : t("options.lock")}
</button>
<button
className="danger"
data-testid="opt-remove"
style={{ color: "var(--danger-text)" }}
onClick={() => {
if (confirm(t("options.removeConfirm"))) actions.removeBoard();
}}
>
{t("options.remove")}
</button>
</div>
</>
)}
</Drawer>
);
}