Sternboard: free retro board, no signup (initial)
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user