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,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sternboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2074
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "sternboard-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^9.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Identidad sin registro: UUID estable en localStorage + nombre y token por board.
|
||||
|
||||
export function getParticipantId(): string {
|
||||
let id = localStorage.getItem("participantId");
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem("participantId", id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getName(boardId: string): string | null {
|
||||
return localStorage.getItem(`name:${boardId}`);
|
||||
}
|
||||
export function setName(boardId: string, name: string) {
|
||||
localStorage.setItem(`name:${boardId}`, name);
|
||||
}
|
||||
|
||||
// Color de post-it preferido del participante (paleta de 12).
|
||||
export function getPenColor(): string {
|
||||
return localStorage.getItem("penColor") || "#ffe08a";
|
||||
}
|
||||
export function setPenColor(color: string) {
|
||||
localStorage.setItem("penColor", color);
|
||||
}
|
||||
|
||||
export function getModeratorToken(boardId: string): string | undefined {
|
||||
return localStorage.getItem(`mod:${boardId}`) ?? undefined;
|
||||
}
|
||||
export function setModeratorToken(boardId: string, token: string) {
|
||||
localStorage.setItem(`mod:${boardId}`, token);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { ThemeProvider } from "./theme";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Board } from "./pages/Board";
|
||||
import "./styles.css";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/", element: <Home /> },
|
||||
{ path: "/b/:id", element: <Board /> },
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<RouterProvider router={router} />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useI18n } from "../i18n";
|
||||
import { AppBar } from "../components/AppBar";
|
||||
import { setModeratorToken } from "../identity";
|
||||
import type { Template } from "../types";
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
{ hours: null as number | null, key: "never" as const },
|
||||
{ hours: 2, label: "2h" },
|
||||
{ hours: 8, label: "8h" },
|
||||
{ hours: 24, label: "1d" },
|
||||
{ hours: 24 * 7, label: "7d" },
|
||||
];
|
||||
|
||||
export function Home() {
|
||||
const { t } = useI18n();
|
||||
const nav = useNavigate();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [templateId, setTemplateId] = useState("mad-sad-glad");
|
||||
const [customCols, setCustomCols] = useState<string[]>(["", "", ""]);
|
||||
const [votesPerUser, setVotesPerUser] = useState(3);
|
||||
const [ttlHours, setTtlHours] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/templates").then((r) => r.json()).then(setTemplates).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const isCustom = templateId === "custom";
|
||||
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = { title, votesPerUser, ttlHours };
|
||||
if (isCustom) body.columns = customCols.map((c) => c.trim()).filter(Boolean);
|
||||
else body.templateId = templateId;
|
||||
const res = await fetch("/api/boards", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
setModeratorToken(data.boardId, data.moderatorToken);
|
||||
nav(`/b/${data.boardId}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar />
|
||||
<div className="home-wrap">
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10, marginBottom: 18 }}>
|
||||
<span className="section-num">01</span>
|
||||
<h3 style={{ margin: 0, fontSize: 16, color: "var(--text-strong)" }}>{t("home.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="hero-grid">
|
||||
<div className="hero">
|
||||
<div className="hero-brand">
|
||||
<span className="brand-logo">S</span>
|
||||
<span className="brand-name">sternboard</span>
|
||||
</div>
|
||||
<p className="lead">{t("home.lead")}</p>
|
||||
<div className="feature"><span className="ic">🙅</span> {t("home.feat1")}</div>
|
||||
<div className="feature"><span className="ic">⏱</span> {t("home.feat2")}</div>
|
||||
<div className="feature"><span className="ic">🧭</span> {t("home.feat3")}</div>
|
||||
<div className="feature"><span className="ic">💣</span> {t("home.feat4")}</div>
|
||||
<div className="feature"><span className="ic">🔒</span> {t("home.feat5")}</div>
|
||||
</div>
|
||||
|
||||
<div className="create-card">
|
||||
<h2>{t("home.newBoard")}</h2>
|
||||
|
||||
<label className="field-label">{t("home.boardTitle")}</label>
|
||||
<input
|
||||
data-testid="board-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Sprint 42 · Retro"
|
||||
/>
|
||||
|
||||
<label className="field-label">{t("home.template")}</label>
|
||||
<select
|
||||
data-testid="template"
|
||||
value={templateId}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
>
|
||||
{templates
|
||||
.filter((tpl) => tpl.id !== "blank")
|
||||
.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name} · {tpl.columns.length} col
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">✨ {t("home.custom")}</option>
|
||||
</select>
|
||||
|
||||
{isCustom && (
|
||||
<CustomColumns cols={customCols} setCols={setCustomCols} />
|
||||
)}
|
||||
|
||||
<label className="field-label">{t("home.votesPerUser")}</label>
|
||||
<div className="stepper" style={{ marginBottom: 18 }}>
|
||||
<button data-testid="votes-dec" onClick={() => setVotesPerUser((v) => Math.max(0, v - 1))}>
|
||||
−
|
||||
</button>
|
||||
<span className="val" data-testid="votes-val">{votesPerUser}</span>
|
||||
<button data-testid="votes-inc" onClick={() => setVotesPerUser((v) => Math.min(50, v + 1))}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="field-label">💣 {t("home.selfDestruct")}</label>
|
||||
<div className="ttl-row" style={{ marginBottom: 22 }}>
|
||||
{TTL_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.label ?? "never"}
|
||||
className={ttlHours === o.hours ? "chip on" : "chip"}
|
||||
onClick={() => setTtlHours(o.hours)}
|
||||
>
|
||||
{o.key === "never" ? t("home.never") : o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="cta" data-testid="create" disabled={busy} onClick={create}>
|
||||
{busy ? t("home.creating") : `${t("home.create")} →`}
|
||||
</button>
|
||||
<ImportLink />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomColumns({
|
||||
cols,
|
||||
setCols,
|
||||
}: {
|
||||
cols: string[];
|
||||
setCols: (c: string[]) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const dots = ["#ef4444", "#eab308", "#22c55e", "#3b82f6", "#a855f7", "#ec4899"];
|
||||
return (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<label className="field-label">{t("home.columns")}</label>
|
||||
<div className="col-rows">
|
||||
{cols.map((c, i) => (
|
||||
<div className="col-row" key={i}>
|
||||
<span className="col-dot" style={{ background: dots[i % dots.length] }} />
|
||||
<input
|
||||
data-testid="custom-col"
|
||||
value={c}
|
||||
placeholder={t("home.columnName")}
|
||||
onChange={(e) => setCols(cols.map((x, j) => (j === i ? e.target.value : x)))}
|
||||
/>
|
||||
{cols.length > 1 && (
|
||||
<button className="del" onClick={() => setCols(cols.filter((_, j) => j !== i))}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button data-testid="add-col" className="add-col" onClick={() => setCols([...cols, ""])}>
|
||||
{t("home.addColumn")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportLink() {
|
||||
const { t } = useI18n();
|
||||
const nav = useNavigate();
|
||||
const [mode, setMode] = useState<"full" | "structure">("full");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setErr("");
|
||||
try {
|
||||
const snapshot = JSON.parse(await file.text());
|
||||
const res = await fetch("/api/boards/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot, mode }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setModeratorToken(data.boardId, data.moderatorToken);
|
||||
nav(`/b/${data.boardId}`);
|
||||
} catch {
|
||||
setErr("⚠️ JSON inválido");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<p className="muted-note">
|
||||
{t("home.importQuestion")}{" "}
|
||||
<button className="link" data-testid="import-toggle" onClick={() => setOpen((o) => !o)}>
|
||||
{t("home.importJson")}
|
||||
</button>
|
||||
</p>
|
||||
{open && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div className="ttl-row" style={{ justifyContent: "center", marginBottom: 8 }}>
|
||||
<button className={mode === "full" ? "chip on" : "chip"} onClick={() => setMode("full")}>
|
||||
{t("home.importFull")}
|
||||
</button>
|
||||
<button
|
||||
className={mode === "structure" ? "chip on" : "chip"}
|
||||
onClick={() => setMode("structure")}
|
||||
>
|
||||
{t("home.importStructure")}
|
||||
</button>
|
||||
</div>
|
||||
<label className="btn">
|
||||
{t("home.importBtn")}
|
||||
<input
|
||||
data-testid="import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={onFile}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</label>
|
||||
{err && <span className="err"> {err}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Alarma corta generada con Web Audio (sin ficheros). Dos pitidos rápidos.
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
export function playAlarm() {
|
||||
try {
|
||||
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
ctx = ctx ?? new Ctor();
|
||||
if (ctx.state === "suspended") void ctx.resume();
|
||||
const now = ctx.currentTime;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = "square";
|
||||
osc.frequency.value = 880;
|
||||
const t = now + i * 0.18;
|
||||
gain.gain.setValueAtTime(0.0001, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.25, t + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.15);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.16);
|
||||
}
|
||||
} catch {
|
||||
// sin audio disponible: ignoramos
|
||||
}
|
||||
}
|
||||
|
||||
// Parsea "mm", "mm:ss" o "m.5" a segundos. Devuelve null si no es válido.
|
||||
export function parseTime(input: string): number | null {
|
||||
const s = input.trim();
|
||||
if (!s) return null;
|
||||
if (s.includes(":")) {
|
||||
const [m, sec] = s.split(":");
|
||||
const mm = Number(m);
|
||||
const ss = Number(sec);
|
||||
if (!Number.isFinite(mm) || !Number.isFinite(ss) || ss < 0 || ss >= 60) return null;
|
||||
const total = Math.round(mm * 60 + ss);
|
||||
return total > 0 ? total : null;
|
||||
}
|
||||
const mins = Number(s);
|
||||
if (!Number.isFinite(mins) || mins <= 0) return null;
|
||||
return Math.round(mins * 60);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/* ===== Sistema de tema (Hefestia: grafito + acento ámbar) ===== */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--page-bg: #0a0f17;
|
||||
--header-bg: #1f2937;
|
||||
--toolbar-bg: #131a26;
|
||||
--col-bg: #141b27;
|
||||
--col-border: #243044;
|
||||
--inset: #0c121c;
|
||||
--chip-bg: #111827;
|
||||
--border: #374151;
|
||||
--divider: #2a3852;
|
||||
--btn-bg: #1f2937;
|
||||
--seg-bg: #0c121c;
|
||||
--text-strong: #ffffff;
|
||||
--text: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--text-meta: #6b7280;
|
||||
--pos-text: #86efac;
|
||||
--accent-text: #fbbf24;
|
||||
--danger-text: #fca5a5;
|
||||
--card-bg: #1f2937;
|
||||
--card-border: #374151;
|
||||
--input-bg: #111827;
|
||||
|
||||
--accent: #d97706;
|
||||
--accent-2: #b45309;
|
||||
--accent-tint: rgba(245, 158, 11, 0.12);
|
||||
--ok: #22a957;
|
||||
--danger: #f0617a;
|
||||
--postit: 200px; /* tamaño fijo del post-it (simula uno real) */
|
||||
|
||||
/* aliases para el CSS existente */
|
||||
--bg: var(--page-bg);
|
||||
--panel: var(--col-bg);
|
||||
--panel2: var(--btn-bg);
|
||||
--muted: var(--text-muted);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--page-bg: #eceff3;
|
||||
--header-bg: #ffffff;
|
||||
--toolbar-bg: #f5f7fa;
|
||||
--col-bg: #ffffff;
|
||||
--col-border: #e4e8ef;
|
||||
--inset: #eef1f5;
|
||||
--chip-bg: #eef1f5;
|
||||
--border: #dbe1ea;
|
||||
--divider: #dfe5ee;
|
||||
--btn-bg: #f1f4f8;
|
||||
--seg-bg: #e7ebf1;
|
||||
--text-strong: #0b1220;
|
||||
--text: #1c2533;
|
||||
--text-muted: #5b6677;
|
||||
--text-meta: #8b94a3;
|
||||
--pos-text: #15803d;
|
||||
--accent-text: #b45309;
|
||||
--danger-text: #dc2626;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #e4e8ef;
|
||||
--input-bg: #f5f7fa;
|
||||
|
||||
--accent: #d97706;
|
||||
--accent-2: #b45309;
|
||||
--accent-tint: rgba(245, 158, 11, 0.14);
|
||||
--ok: #15803d;
|
||||
--danger: #dc2626;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--page-bg); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page-bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection { background: rgba(245, 158, 11, 0.3); }
|
||||
|
||||
button, .btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.primary, .primary {
|
||||
background: var(--accent); border-color: var(--accent); color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(217, 119, 6, 0.32);
|
||||
}
|
||||
button.primary:hover, .primary:hover { background: var(--accent-2); border-color: var(--accent-2); }
|
||||
input, textarea, select {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 9px;
|
||||
padding: 9px 11px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||
input[type="checkbox"], input[type="radio"] { width: auto; }
|
||||
textarea { resize: vertical; min-height: 44px; }
|
||||
|
||||
/* ===== Barra superior / marca ===== */
|
||||
.appbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 13px 24px; background: var(--header-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
}
|
||||
.brand { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.brand-logo {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #1a0a00; font-weight: 800; font-size: 15px; flex-shrink: 0;
|
||||
}
|
||||
.brand-name { font-size: 17px; font-weight: 700; color: var(--text-strong); letter-spacing: -0.01em; }
|
||||
.appbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.seg {
|
||||
display: inline-flex; background: var(--seg-bg); border: 1px solid var(--border);
|
||||
border-radius: 9px; padding: 2px;
|
||||
}
|
||||
.seg button {
|
||||
border: none; border-radius: 7px; height: 28px; padding: 0 11px;
|
||||
font-size: 12px; font-weight: 700; cursor: pointer; background: transparent; color: var(--text-muted);
|
||||
}
|
||||
.seg button.on { background: #fbbf24; color: #1a0a00; }
|
||||
.seg button:hover { border: none; }
|
||||
|
||||
/* ===== Home ===== */
|
||||
.home-wrap { max-width: 1080px; margin: 0 auto; padding: 40px 28px 96px; }
|
||||
.hero-grid { display: grid; grid-template-columns: 1fr 440px; gap: 44px; align-items: center; }
|
||||
@media (max-width: 880px) { .hero-grid { grid-template-columns: 1fr; } }
|
||||
.hero-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
|
||||
.hero-brand .brand-logo { width: 44px; height: 44px; border-radius: 12px; font-size: 22px; box-shadow: 0 6px 18px rgba(245,158,11,0.32); }
|
||||
.hero-brand .brand-name { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; }
|
||||
.hero p.lead { margin: 0 0 26px; font-size: 16px; line-height: 1.55; color: var(--text-muted); max-width: 380px; }
|
||||
.feature { display: flex; gap: 11px; align-items: center; font-size: 14px; color: var(--text); margin-bottom: 13px; }
|
||||
.feature .ic { width: 26px; height: 26px; border-radius: 7px; background: var(--inset); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
|
||||
.create-card {
|
||||
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 16px;
|
||||
padding: 24px; box-shadow: 0 14px 40px rgba(0,0,0,0.18);
|
||||
}
|
||||
.create-card h2 { margin: 0 0 20px; font-size: 18px; font-weight: 700; color: var(--text-strong); }
|
||||
.field-label {
|
||||
display: block; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-muted); font-weight: 600; margin: 0 0 7px;
|
||||
}
|
||||
.create-card input, .create-card select { margin-bottom: 18px; }
|
||||
.stepper { display: flex; align-items: center; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--input-bg); }
|
||||
.stepper button { border: none; background: transparent; color: var(--text-muted); padding: 9px 14px; font-size: 16px; }
|
||||
.stepper .val { flex: 1; text-align: center; font-family: ui-monospace, monospace; font-size: 15px; color: var(--text-strong); font-weight: 600; }
|
||||
.cta { width: 100%; border: none; background: var(--accent); color: #fff; border-radius: 10px; padding: 13px; font-size: 15px; font-weight: 600; cursor: pointer; box-shadow: 0 4px 14px rgba(217,119,6,0.32); }
|
||||
.cta:hover { background: var(--accent-2); }
|
||||
.muted-note { margin: 14px 0 0; text-align: center; font-size: 12px; color: var(--text-meta); }
|
||||
.section-num { font-family: ui-monospace, monospace; font-size: 13px; color: #f59e0b; font-weight: 600; }
|
||||
|
||||
/* chips de autodestrucción */
|
||||
.ttl-row { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
padding: 7px 13px; border: 1px solid var(--border); border-radius: 9999px;
|
||||
font-size: 13px; color: var(--text); background: var(--input-bg); cursor: pointer;
|
||||
}
|
||||
.chip.on { border: 1.5px solid #f59e0b; color: var(--text-strong); background: var(--accent-tint); }
|
||||
|
||||
/* columnas personalizadas (creación libre) */
|
||||
.col-rows { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; }
|
||||
.col-row { display: flex; align-items: center; gap: 8px; background: var(--input-bg); border: 1px solid var(--border); border-radius: 9px; padding: 5px 6px 5px 10px; }
|
||||
.col-row input { margin: 0; border: none; background: transparent; padding: 4px 0; }
|
||||
.col-row .col-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.col-row .del { border: none; background: transparent; color: var(--text-meta); padding: 6px 9px; }
|
||||
.add-col { width: 100%; border: 1.5px dashed var(--border); background: transparent; color: var(--text-muted); border-radius: 9px; padding: 11px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* ===== layout general ===== */
|
||||
.tagline { color: var(--text-muted); }
|
||||
.lang { display: inline-flex; gap: 4px; }
|
||||
|
||||
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 14px; padding: 22px; box-shadow: 0 14px 40px rgba(0,0,0,0.18); }
|
||||
.form { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form label { color: var(--text-muted); font-size: 13px; margin-top: 8px; }
|
||||
.form .primary, .form .cta { margin-top: 16px; }
|
||||
|
||||
.centered { display: grid; place-items: center; min-height: 80vh; padding: 24px; }
|
||||
.centered .card { width: min(440px, 100%); }
|
||||
.err { color: var(--danger); font-size: 13px; }
|
||||
|
||||
/* pantallas de estado (no encontrado / autodestruido) */
|
||||
.state-card { text-align: center; max-width: 420px; }
|
||||
.state-icon { width: 60px; height: 60px; border-radius: 16px; background: var(--inset); display: flex; align-items: center; justify-content: center; font-size: 28px; margin: 0 auto 18px; }
|
||||
.state-icon.danger { background: rgba(220,38,38,0.12); border: 1px solid rgba(220,38,38,0.25); }
|
||||
.state-card h2 { margin: 0 0 8px; font-size: 19px; font-weight: 700; color: var(--text-strong); }
|
||||
.state-card p { margin: 0 0 22px; font-size: 14px; line-height: 1.5; color: var(--text-muted); }
|
||||
|
||||
/* enroll / unirse */
|
||||
.enroll-kicker { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-meta); font-weight: 600; margin-bottom: 6px; }
|
||||
.enroll-title { margin: 0 0 12px; font-size: 24px; font-weight: 800; letter-spacing: -0.01em; color: var(--text-strong); }
|
||||
.enroll-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 22px; }
|
||||
.enroll-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); background: var(--inset); border: 1px solid var(--border); border-radius: 9999px; padding: 4px 11px; }
|
||||
.enroll-chip.danger { color: var(--danger-text); }
|
||||
.enroll-chip .mono { font-family: ui-monospace, monospace; }
|
||||
|
||||
/* ===== Board ===== */
|
||||
.board { padding-bottom: 48px; }
|
||||
.topbar { display: flex; align-items: center; gap: 12px; padding: 13px 24px; background: var(--header-bg); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||
.topbar h1 { font-size: 17px; margin: 0; color: var(--text-strong); font-weight: 700; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 24px; border-bottom: 1px solid var(--border); }
|
||||
.votes-left { color: var(--text-muted); font-size: 13px; }
|
||||
.expiry { color: var(--danger-text); font-size: 13px; margin-left: auto; font-family: ui-monospace, monospace; }
|
||||
.badge { background: var(--accent-tint); color: var(--accent-text); border: 1px solid rgba(245,158,11,0.4); border-radius: 7px; padding: 2px 8px; font-size: 12px; font-weight: 600; }
|
||||
|
||||
.timer { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.timer .clock { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-weight: 600; min-width: 52px; text-align: center; color: var(--text-strong); }
|
||||
.timer .clock.up { color: var(--danger); animation: blink 1s steps(2, start) infinite; }
|
||||
.timer-input { width: 64px; text-align: center; padding: 4px 6px; }
|
||||
@keyframes blink { to { opacity: 0.35; } }
|
||||
|
||||
.columns { display: flex; gap: 18px; padding: 20px 24px 32px; overflow-x: auto; align-items: start; min-height: 60vh; }
|
||||
.column { background: var(--col-bg); border: 1px solid var(--col-border); border-radius: 14px; padding: 16px; min-width: 444px; flex: 1; display: flex; flex-direction: column; }
|
||||
.col-head { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; }
|
||||
.col-head h3 { margin: 0; font-size: 16px; font-weight: 700; color: var(--text-strong); }
|
||||
.col-count { font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-muted); background: var(--inset); border-radius: 9999px; padding: 2px 9px; margin-left: auto; }
|
||||
.add-note { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||
.add-note button { white-space: nowrap; }
|
||||
|
||||
/* área pública: post-its de tamaño FIJO, varios por fila (se ajustan al ancho) */
|
||||
.public-area { display: grid; grid-template-columns: repeat(auto-fill, var(--postit)); gap: 10px; align-content: start; justify-content: center; min-height: 80px; border-radius: 8px; padding: 2px; }
|
||||
.public-area.over { background: var(--accent-tint); }
|
||||
.no-cards { grid-column: 1 / -1; color: var(--text-meta); text-align: center; font-size: 13px; padding: 24px 0; }
|
||||
|
||||
/* post-it cuadrado de tamaño fijo */
|
||||
.note.postit {
|
||||
background: #ffe08a; color: #20242c; border: none; border-radius: 12px;
|
||||
padding: 12px 12px 9px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35);
|
||||
width: var(--postit); height: var(--postit);
|
||||
display: flex; flex-direction: column; gap: 6px; overflow: hidden; position: relative;
|
||||
}
|
||||
.note.postit .note-handle { color: rgba(0, 0, 0, 0.42); }
|
||||
/* texto: tamaño dinámico vía inline style; si no cabe (suelo 12px) aparece scroll */
|
||||
.note-edit, .note.postit p { flex: 1; overflow: auto; margin: 0; background: transparent; border: none; color: inherit; padding: 0; width: 100%; resize: none; line-height: 1.3; font-weight: 500; word-break: break-word; white-space: pre-wrap; }
|
||||
.note-edit:focus { outline: none; }
|
||||
.note-foot { display: flex; align-items: center; gap: 6px; margin-top: 0; }
|
||||
.avatar { width: 19px; height: 19px; border-radius: 50%; background: rgba(0, 0, 0, 0.14); display: flex; align-items: center; justify-content: center; font-size: 8px; font-weight: 700; color: rgba(0, 0, 0, 0.6); flex-shrink: 0; }
|
||||
.author { color: rgba(0, 0, 0, 0.5); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.note .note-foot button { background: rgba(0, 0, 0, 0.06); border: 1px solid rgba(0, 0, 0, 0.18); color: #20242c; padding: 3px 9px; font-size: 11px; font-weight: 700; border-radius: 9999px; }
|
||||
.vote-control { display: inline-flex; align-items: center; gap: 3px; }
|
||||
.note .vote-minus { padding: 2px 7px; }
|
||||
.note .vote.voted { border-color: #16a34a; color: #137a36; background: rgba(34, 163, 74, 0.18); }
|
||||
.note .make-private { padding: 3px 7px; }
|
||||
.note .del { color: #b4232f; }
|
||||
.note .publish { background: rgba(22, 163, 74, 0.9); border: none; color: #fff; }
|
||||
|
||||
/* notas dentro de grupo y cuadrícula privada (post-its fijos) */
|
||||
.group-grid, .private-grid { display: grid; grid-template-columns: repeat(auto-fill, var(--postit)); gap: 8px; justify-content: start; }
|
||||
.private-grid { margin-bottom: 10px; }
|
||||
.group .note.postit { box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3); }
|
||||
.note.postit.draft { outline: 2px dashed rgba(168, 85, 247, 0.55); outline-offset: 2px; }
|
||||
|
||||
/* compositor privado */
|
||||
.private-section { border-top: 1px dashed var(--divider); margin-top: 14px; padding-top: 14px; }
|
||||
.private-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.private-head span { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--pos-text); font-weight: 600; }
|
||||
.link { background: none; border: none; color: var(--accent-text); padding: 0; font-weight: 600; cursor: pointer; font-size: 12px; }
|
||||
.swatches { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 10px; }
|
||||
.swatch { width: 21px; height: 21px; border-radius: 7px; cursor: pointer; }
|
||||
.swatch.on { box-shadow: 0 0 0 2px var(--col-bg), 0 0 0 4px #f59e0b; }
|
||||
.postit-input { color: rgba(20, 24, 28, 0.85); border: none; border-radius: 9px; padding: 13px; min-height: 54px; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25); resize: vertical; }
|
||||
.postit-input::placeholder { color: rgba(0, 0, 0, 0.45); }
|
||||
|
||||
/* drag & drop */
|
||||
.note.dragging, .group.dragging { opacity: 0.4; }
|
||||
.note-handle { position: absolute; top: 4px; right: 5px; cursor: grab; font-size: 13px; line-height: 1; user-select: none; touch-action: none; z-index: 1; }
|
||||
.note-handle:active { cursor: grabbing; }
|
||||
.group-handle { touch-action: none; }
|
||||
.note.group-target { outline: 2px solid var(--ok); outline-offset: 1px; }
|
||||
|
||||
/* grupos (fila completa) */
|
||||
.group { grid-column: 1 / -1; background: var(--inset); border: 1px solid var(--divider); border-radius: 12px; padding: 10px; }
|
||||
.group.over { outline: 2px dashed var(--accent); }
|
||||
.group-head { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; padding: 0 2px; }
|
||||
.group-head input { flex: 1; background: transparent; border: none; font-weight: 700; padding: 4px 6px; color: var(--text-strong); font-size: 13px; }
|
||||
.group-head input:focus { background: var(--input-bg); }
|
||||
.group-handle { cursor: grab; color: var(--text-muted); padding: 0 4px; }
|
||||
.group-count { font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-muted); background: var(--btn-bg); border-radius: 9999px; padding: 1px 7px; }
|
||||
.group-head .vote { background: rgba(34, 197, 94, 0.16) !important; border: 1px solid #16a34a !important; color: var(--pos-text) !important; }
|
||||
|
||||
.vote.hiddenTotals { font-variant-numeric: tabular-nums; }
|
||||
.reveal-on { border-color: var(--ok); color: var(--pos-text); }
|
||||
|
||||
.overlay-note { color: #20242c; border: 1px solid var(--accent); border-radius: 10px; padding: 11px; width: 150px; aspect-ratio: 1/1; box-shadow: 0 8px 24px rgba(0,0,0,0.4); overflow: hidden; font-size: 12.5px; }
|
||||
|
||||
/* modales y drawers */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: grid; place-items: center; z-index: 50; }
|
||||
.modal { width: min(420px, 92vw); }
|
||||
.modal .row { display: flex; gap: 8px; align-items: center; justify-content: space-between; margin: 10px 0; }
|
||||
.modal .row label { color: var(--text-muted); }
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
||||
.toggle { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
||||
.drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 50; }
|
||||
.drawer { position: absolute; top: 0; right: 0; height: 100%; width: min(440px, 92vw); background: var(--card-bg); border-left: 1px solid var(--border); display: flex; flex-direction: column; box-shadow: -8px 0 24px rgba(0,0,0,0.4); }
|
||||
.drawer-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.drawer-head h2 { margin: 0; font-size: 18px; color: var(--text-strong); }
|
||||
.drawer-body { padding: 16px 20px; overflow-y: auto; }
|
||||
.icon { background: none; border: none; color: var(--text-muted); font-size: 16px; padding: 4px 8px; }
|
||||
.panel-section { font-size: 14px; color: var(--text-muted); margin: 18px 0 8px; }
|
||||
.toggle-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; }
|
||||
.people, .action-list { list-style: none; padding: 0; margin: 0; }
|
||||
.people li { padding: 8px 0; border-bottom: 1px solid var(--divider); }
|
||||
.me { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.me em { color: var(--text-muted); }
|
||||
|
||||
.add-action { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.add-action input { flex: 1; }
|
||||
.action-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
|
||||
.action-row .action-text { flex: 1; }
|
||||
.action-row .owner { max-width: 120px; }
|
||||
.action-list.done .action-row .action-text { text-decoration: line-through; color: var(--text-muted); }
|
||||
|
||||
.menu-backdrop { position: fixed; inset: 0; z-index: 50; }
|
||||
.options-menu { position: absolute; top: 150px; left: 24px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 6px; min-width: 240px; box-shadow: 0 12px 32px rgba(0,0,0,0.5); display: flex; flex-direction: column; }
|
||||
.options-menu .menu-section { font-size: 12px; color: var(--text-muted); padding: 8px 10px 4px; }
|
||||
.options-menu .menu-section.danger { color: var(--danger-text); }
|
||||
.options-menu button { text-align: left; background: none; border: none; padding: 8px 10px; border-radius: 6px; }
|
||||
.options-menu button:hover { background: var(--btn-bg); }
|
||||
.options-menu button.danger { color: var(--danger-text); }
|
||||
|
||||
.export-modal { width: min(820px, 94vw); }
|
||||
.export-grid { display: grid; grid-template-columns: 240px 1fr; gap: 16px; margin-top: 8px; }
|
||||
.export-preview textarea { width: 100%; min-height: 340px; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
|
||||
.pill { background: var(--inset); color: var(--text-muted); border-radius: 9999px; padding: 1px 7px; font-size: 11px; font-family: ui-monospace, monospace; margin-left: 2px; }
|
||||
.pill.amber { background: #fbbf24; color: #1a0a00; }
|
||||
.locked-banner { color: var(--danger-text); font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* ===== cabecera del tablero ===== */
|
||||
.timer-wrap { margin: 0 auto; }
|
||||
.topbar .expiry {
|
||||
margin: 0; color: var(--danger-text); font-size: 12px; font-family: ui-monospace, monospace;
|
||||
background: var(--inset); border: 1px solid var(--border); border-radius: 9999px; padding: 4px 10px;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.votes-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text); background: var(--chip-bg); border: 1px solid var(--border); border-radius: 9999px; padding: 6px 13px; white-space: nowrap; }
|
||||
.votes-pill b { color: var(--pos-text); font-family: ui-monospace, monospace; }
|
||||
.share-btn { background: var(--accent) !important; color: #fff !important; border: none !important; box-shadow: 0 3px 10px rgba(217,119,6,0.3); font-weight: 600; }
|
||||
|
||||
/* ===== barra de herramientas ===== */
|
||||
.toolbar { background: var(--toolbar-bg, var(--col-bg)); }
|
||||
.phase-tabs { display: inline-flex; background: var(--seg-bg); border: 1px solid var(--border); border-radius: 9px; padding: 3px; }
|
||||
.phase-tabs button { border: none; background: transparent; color: var(--text-muted); border-radius: 6px; padding: 7px 14px; font-size: 12.5px; font-weight: 500; }
|
||||
.phase-tabs button.on { background: #fbbf24; color: #1a0a00; font-weight: 600; }
|
||||
.phase-tabs button:disabled { cursor: default; }
|
||||
.phase-tabs button:hover { border: none; }
|
||||
.tb-divider { width: 1px; height: 24px; background: var(--border); }
|
||||
.tb-btn { background: transparent; }
|
||||
.tb-votes { background: var(--btn-bg); color: var(--pos-text); border: 1px solid var(--border); }
|
||||
.tb-votes.on { border-color: var(--ok); background: rgba(34,197,94,0.12); }
|
||||
.tb-hint { margin-left: auto; font-size: 12px; color: var(--text-meta); white-space: nowrap; }
|
||||
|
||||
/* ===== menú de columna ===== */
|
||||
.col-head input { font-size: 16px; font-weight: 700; padding: 6px 8px; }
|
||||
.col-menu-wrap { position: relative; display: inline-flex; }
|
||||
.col-menu-btn { border: none; background: transparent; color: var(--text-muted); font-size: 18px; line-height: 1; padding: 0 4px; }
|
||||
.col-menu { position: absolute; right: 0; top: 26px; z-index: 60; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 6px; min-width: 170px; box-shadow: 0 12px 32px rgba(0,0,0,0.5); display: flex; flex-direction: column; }
|
||||
.col-menu button { text-align: left; background: none; border: none; padding: 8px 10px; border-radius: 6px; font-size: 13px; }
|
||||
.col-menu button:hover { background: var(--btn-bg); }
|
||||
.col-menu button.danger { color: var(--danger-text); }
|
||||
.col-menu button:disabled { opacity: 0.4; }
|
||||
|
||||
/* ===== añadir columna ===== */
|
||||
.add-column { align-self: start; min-width: 180px; min-height: 200px; background: transparent; border: 2px dashed var(--col-border); border-radius: 14px; color: var(--text-muted); font-size: 14px; font-weight: 600; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 24px 14px; }
|
||||
.add-column:hover { border-color: var(--accent); color: var(--text); }
|
||||
.add-column-plus { width: 38px; height: 38px; border-radius: 11px; background: var(--inset); display: flex; align-items: center; justify-content: center; font-size: 22px; line-height: 1; }
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
const Ctx = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
|
||||
|
||||
function detect(): Theme {
|
||||
const saved = localStorage.getItem("theme");
|
||||
return saved === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(detect);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
const setTheme = (t: Theme) => {
|
||||
localStorage.setItem("theme", t);
|
||||
setThemeState(t);
|
||||
};
|
||||
return <Ctx.Provider value={{ theme, setTheme }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const c = useContext(Ctx);
|
||||
if (!c) throw new Error("useTheme fuera de ThemeProvider");
|
||||
return c;
|
||||
}
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<div className="seg" role="group" aria-label="theme">
|
||||
<button
|
||||
className={theme === "light" ? "on" : ""}
|
||||
title="Modo claro"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
☀
|
||||
</button>
|
||||
<button
|
||||
className={theme === "dark" ? "on" : ""}
|
||||
title="Modo oscuro"
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
🌙
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type Note = {
|
||||
id: string;
|
||||
columnId: string;
|
||||
groupId: string | null;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
text: string;
|
||||
color: string | null;
|
||||
revealed: boolean;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type Group = { id: string; columnId: string; title: string; position: number };
|
||||
|
||||
export type BoardState = {
|
||||
id: string;
|
||||
title: string;
|
||||
phase: string;
|
||||
revealed: boolean;
|
||||
votesRevealed: boolean;
|
||||
votesPerUser: number;
|
||||
allowMultiVote: boolean;
|
||||
moderationMode: "everyone" | "single";
|
||||
showCardCreators: boolean;
|
||||
locked: boolean;
|
||||
createdAt: string;
|
||||
youAreModerator: boolean;
|
||||
participants: { id: string; name: string }[];
|
||||
timerEndsAt: string | null;
|
||||
timerRunning: boolean;
|
||||
expiresAt: string | null;
|
||||
columns: { id: string; title: string; position: number }[];
|
||||
groups: Group[];
|
||||
notes: Note[];
|
||||
votes: { targetType: string; targetId: string; participantId: string; count: number }[];
|
||||
actionItems: { id: string; text: string; owner: string | null; done: boolean }[];
|
||||
};
|
||||
|
||||
export type Template = { id: string; name: string; columns: string[] };
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import type { BoardState } from "./types";
|
||||
import { getModeratorToken, setModeratorToken } from "./identity";
|
||||
|
||||
export type BoardStatus = "connecting" | "ready" | "notfound" | "destroyed";
|
||||
|
||||
export function useBoard(boardId: string, participantId: string, name: string | null) {
|
||||
const [state, setState] = useState<BoardState | null>(null);
|
||||
const [status, setStatus] = useState<BoardStatus>("connecting");
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
const socket = io({ path: "/socket.io" });
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () =>
|
||||
socket.emit("board:join", { boardId, participantId, name, moderatorToken: getModeratorToken(boardId) }),
|
||||
);
|
||||
socket.on("board:state", (s: BoardState) => {
|
||||
setState(s);
|
||||
setStatus("ready");
|
||||
});
|
||||
socket.on("board:claimed", (p: { moderatorToken: string }) => {
|
||||
setModeratorToken(boardId, p.moderatorToken);
|
||||
});
|
||||
socket.on("error", (e: { message: string }) => {
|
||||
if (e.message === "board_not_found") setStatus("notfound");
|
||||
});
|
||||
socket.on("board:destroyed", () => setStatus("destroyed"));
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [boardId, participantId, name]);
|
||||
|
||||
const mod = () => getModeratorToken(boardId);
|
||||
const emit = (event: string, payload: Record<string, unknown> = {}) =>
|
||||
socketRef.current?.emit(event, payload);
|
||||
const modEmit = (event: string, payload: Record<string, unknown> = {}) =>
|
||||
emit(event, { ...payload, moderatorToken: mod() });
|
||||
|
||||
return {
|
||||
state,
|
||||
status,
|
||||
isModerator: state?.youAreModerator ?? false,
|
||||
actions: {
|
||||
// notas
|
||||
addNote: (columnId: string, text: string, color?: string) =>
|
||||
emit("note:add", { columnId, text, color }),
|
||||
updateNote: (noteId: string, text: string) => emit("note:update", { noteId, text }),
|
||||
deleteNote: (noteId: string) => emit("note:delete", { noteId }),
|
||||
revealNote: (noteId: string, revealed: boolean) => emit("note:reveal", { noteId, revealed }),
|
||||
publishMine: (columnId: string) => emit("note:publishMine", { columnId }),
|
||||
recolorMine: (color: string) => emit("note:recolorMine", { color }),
|
||||
moveNote: (noteId: string, columnId: string, groupId: string | null, position: number) =>
|
||||
emit("note:move", { noteId, columnId, groupId, position }),
|
||||
// grupos
|
||||
createGroup: (noteId: string, targetNoteId: string) =>
|
||||
emit("group:create", { noteId, targetNoteId }),
|
||||
renameGroup: (groupId: string, title: string) => emit("group:rename", { groupId, title }),
|
||||
moveGroup: (groupId: string, columnId: string, position: number) =>
|
||||
emit("group:move", { groupId, columnId, position }),
|
||||
deleteGroup: (groupId: string) => emit("group:delete", { groupId }),
|
||||
// votos
|
||||
addVote: (targetType: "note" | "group", targetId: string) =>
|
||||
emit("vote:add", { targetType, targetId }),
|
||||
removeVote: (targetId: string) => emit("vote:remove", { targetId }),
|
||||
// ajustes y moderación
|
||||
updateBoard: (patch: {
|
||||
title?: string;
|
||||
votesPerUser?: number;
|
||||
allowMultiVote?: boolean;
|
||||
moderationMode?: "everyone" | "single";
|
||||
showCardCreators?: boolean;
|
||||
locked?: boolean;
|
||||
}) => modEmit("board:update", patch),
|
||||
reveal: (revealed: boolean) => modEmit("board:reveal", { revealed }),
|
||||
revealVotes: (revealed: boolean) => modEmit("board:revealVotes", { revealed }),
|
||||
setPhase: (phase: string) => modEmit("board:phase", { phase }),
|
||||
// columnas (moderador)
|
||||
addColumn: (title: string) => modEmit("column:add", { title }),
|
||||
renameColumn: (columnId: string, title: string) => modEmit("column:rename", { columnId, title }),
|
||||
deleteColumn: (columnId: string) => modEmit("column:delete", { columnId }),
|
||||
reorderColumns: (orderedIds: string[]) => modEmit("column:reorder", { orderedIds }),
|
||||
startTimer: (seconds: number) => modEmit("timer:start", { seconds }),
|
||||
stopTimer: () => modEmit("timer:stop"),
|
||||
claim: () => modEmit("board:claim"),
|
||||
clearVotes: () => modEmit("votes:clear"),
|
||||
removeBoard: () => modEmit("board:remove"),
|
||||
// participante
|
||||
renameMe: (name: string) => emit("participant:rename", { name }),
|
||||
// action items
|
||||
addAction: (text: string) => emit("actionitem:add", { text }),
|
||||
updateAction: (id: string, patch: { text?: string; owner?: string | null }) =>
|
||||
emit("actionitem:update", { id, ...patch }),
|
||||
toggleAction: (id: string, done: boolean) => emit("actionitem:toggle", { id, done }),
|
||||
deleteAction: (id: string) => emit("actionitem:delete", { id }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type BoardActions = ReturnType<typeof useBoard>["actions"];
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:3001",
|
||||
"/socket.io": { target: "http://localhost:3001", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user