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,9 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.env
|
||||||
|
**/*.log
|
||||||
|
e2e
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
**/.claude
|
||||||
|
server/drizzle
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: tea.millaguie.net
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Server — install & typecheck
|
||||||
|
working-directory: server
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Web — install, typecheck & build
|
||||||
|
working-directory: web
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: production
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/test-results/
|
||||||
|
e2e/playwright-report/
|
||||||
|
e2e/.cache/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.claude/
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Imagen única: construye el front (Vite) y lo sirve desde el servidor (Fastify + Socket.IO).
|
||||||
|
|
||||||
|
# ---- build del front ----
|
||||||
|
FROM node:22-alpine AS web
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build # -> /web/dist
|
||||||
|
|
||||||
|
# ---- producción ----
|
||||||
|
FROM node:22-alpine AS production
|
||||||
|
WORKDIR /app
|
||||||
|
# deps del servidor (incluye tsx y drizzle-kit; NODE_ENV se fija DESPUÉS para no
|
||||||
|
# saltarse las devDependencies durante la instalación)
|
||||||
|
COPY server/package.json server/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
# código del servidor y front compilado
|
||||||
|
COPY server/ ./
|
||||||
|
COPY --from=web /web/dist ./public
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV WEB_DIST=/app/public
|
||||||
|
ENV PORT=3001
|
||||||
|
EXPOSE 3001
|
||||||
|
# aplica el esquema (drizzle-kit push) y arranca el servidor
|
||||||
|
CMD ["sh", "-c", "npx drizzle-kit push --force && npx tsx src/index.ts"]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Sternboard
|
||||||
|
|
||||||
|
> En náutica, un barco *makes a sternboard* cuando avanza hacia **atrás**, popa
|
||||||
|
> primero. Eso es una retro: mirar atrás para seguir adelante. _stern_ (atrás) +
|
||||||
|
> _board_ (tablero).
|
||||||
|
|
||||||
|
Clon libre de retro board, sin registro. Tableros por columnas con plantillas,
|
||||||
|
revelación de notas a demanda, timer compartido, votos configurables, action
|
||||||
|
items y export a Markdown. Cada board puede tener un **temporizador de
|
||||||
|
autodestrucción** elegido al crearlo.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
- **Servidor autoritativo** (Fastify + Socket.IO). Una room de Socket.IO por
|
||||||
|
board. El estado vive en Postgres (fuente de verdad) y se hidrata en memoria
|
||||||
|
por room; cada acción escribe en DB y hace broadcast del estado al resto.
|
||||||
|
- **Identidad sin login**: el board es un slug no adivinable en la URL. Al entrar
|
||||||
|
se pide nombre (obligatorio) y se genera un `participantId` (UUID) guardado en
|
||||||
|
`localStorage`. El creador recibe un `moderatorToken`.
|
||||||
|
- **Autodestrucción**: `boards.expires_at` se fija al crear; un job borra los
|
||||||
|
expirados y el front muestra la cuenta atrás.
|
||||||
|
|
||||||
|
```
|
||||||
|
web/ Vite + React + TS (UI del board)
|
||||||
|
server/ Fastify + Socket.IO + Drizzle (Postgres)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arrancar en local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Levanta Postgres
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
# 2. Servidor
|
||||||
|
cd server
|
||||||
|
cp .env.example .env
|
||||||
|
npm install
|
||||||
|
npm run db:push # crea las tablas
|
||||||
|
npm run dev # http://localhost:3001
|
||||||
|
|
||||||
|
# 3. Front (otra terminal)
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
O todo en contenedores: `docker compose up --build`.
|
||||||
|
|
||||||
|
## Protocolo Socket.IO
|
||||||
|
|
||||||
|
Cliente → servidor: `board:join`, `note:add`, `note:update`, `note:delete`,
|
||||||
|
`note:move`, `vote:toggle`, `board:reveal`, `board:phase`, `timer:start`,
|
||||||
|
`timer:stop`, `actionitem:add`, `actionitem:update`, `actionitem:delete`.
|
||||||
|
|
||||||
|
Servidor → cliente: `board:state` (estado completo), `error`.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: retro
|
||||||
|
POSTGRES_PASSWORD: retro
|
||||||
|
POSTGRES_DB: retro
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U retro"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: production
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://retro:retro@db:5432/retro
|
||||||
|
PORT: "3001"
|
||||||
|
WEB_ORIGIN: "*"
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
// Crea la base de datos e2e y su esquema desde cero antes de arrancar los servidores.
|
||||||
|
const DDL = `
|
||||||
|
DROP TABLE IF EXISTS action_items, votes, notes, groups, columns, boards CASCADE;
|
||||||
|
CREATE TABLE boards (
|
||||||
|
id text PRIMARY KEY, title text NOT NULL, phase text NOT NULL DEFAULT 'writing',
|
||||||
|
revealed boolean NOT NULL DEFAULT false, votes_revealed boolean NOT NULL DEFAULT false,
|
||||||
|
votes_per_user integer NOT NULL DEFAULT 3, allow_multi_vote boolean NOT NULL DEFAULT true,
|
||||||
|
moderation_mode text NOT NULL DEFAULT 'everyone', show_card_creators boolean NOT NULL DEFAULT true,
|
||||||
|
locked boolean NOT NULL DEFAULT false,
|
||||||
|
timer_ends_at timestamptz, timer_running boolean NOT NULL DEFAULT false,
|
||||||
|
moderator_token text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE TABLE columns (
|
||||||
|
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL, position integer NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE groups (
|
||||||
|
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL DEFAULT '', position double precision NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||||
|
group_id text REFERENCES groups(id) ON DELETE SET NULL,
|
||||||
|
author_id text NOT NULL, author_name text NOT NULL, text text NOT NULL, color text,
|
||||||
|
revealed boolean NOT NULL DEFAULT false, position double precision NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE votes (
|
||||||
|
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
target_type text NOT NULL, target_id text NOT NULL, participant_id text NOT NULL,
|
||||||
|
count integer NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX votes_target_participant_uniq ON votes (target_id, participant_id);
|
||||||
|
CREATE TABLE action_items (
|
||||||
|
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
text text NOT NULL, owner text, done boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
const url = process.env.E2E_DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro_e2e";
|
||||||
|
const dbName = new URL(url).pathname.slice(1);
|
||||||
|
const admin = postgres(url.replace(`/${dbName}`, "/postgres"), { max: 1 });
|
||||||
|
try {
|
||||||
|
const exists = await admin`SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
|
||||||
|
if (exists.length === 0) await admin.unsafe(`CREATE DATABASE ${dbName}`);
|
||||||
|
} finally {
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
|
const sql = postgres(url, { max: 1 });
|
||||||
|
try {
|
||||||
|
await sql.unsafe(DDL);
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+91
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"name": "retropentool-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "retropentool-e2e",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres": {
|
||||||
|
"version": "3.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||||
|
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/porsager"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "sternboard-e2e",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:headed": "playwright test --headed"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const E2E_DB = process.env.E2E_DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro_e2e";
|
||||||
|
const serverDir = fileURLToPath(new URL("../server", import.meta.url));
|
||||||
|
const webDir = fileURLToPath(new URL("../web", import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
globalSetup: "./global-setup.ts",
|
||||||
|
reporter: [["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:5173",
|
||||||
|
locale: "en-US", // i18n -> inglés determinista
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: "npm run serve",
|
||||||
|
cwd: serverDir,
|
||||||
|
port: 3001,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
env: {
|
||||||
|
DATABASE_URL: E2E_DB,
|
||||||
|
PORT: "3001",
|
||||||
|
WEB_ORIGIN: "http://localhost:5173",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "npm run dev",
|
||||||
|
cwd: webDir,
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function createBoard(page: Page, title: string) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("board-title").fill(title);
|
||||||
|
await page.getByTestId("template").selectOption("mad-sad-glad");
|
||||||
|
await page.getByTestId("create").click();
|
||||||
|
await page.waitForURL(/\/b\/.+/);
|
||||||
|
}
|
||||||
|
async function joinAs(page: Page, name: string) {
|
||||||
|
await page.getByTestId("name").fill(name);
|
||||||
|
await page.getByTestId("join").click();
|
||||||
|
await expect(page.getByTestId("column").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("añadir y eliminar columnas", async ({ page }) => {
|
||||||
|
await createBoard(page, "Retro columnas");
|
||||||
|
await joinAs(page, "Ana");
|
||||||
|
await expect(page.getByTestId("column")).toHaveCount(3);
|
||||||
|
|
||||||
|
await page.getByTestId("add-column").click();
|
||||||
|
await expect(page.getByTestId("column")).toHaveCount(4);
|
||||||
|
|
||||||
|
// eliminar la última vía menú ⋯
|
||||||
|
await page.getByTestId("column").last().getByTestId("col-menu").click();
|
||||||
|
await page.getByTestId("col-delete").click();
|
||||||
|
await expect(page.getByTestId("column")).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fases Lluvia/Votación/Discusión", async ({ page }) => {
|
||||||
|
await createBoard(page, "Retro fases");
|
||||||
|
await joinAs(page, "Ana");
|
||||||
|
|
||||||
|
// por defecto Lluvia (writing) activa
|
||||||
|
await expect(page.getByTestId("phase-writing")).toHaveClass(/on/);
|
||||||
|
|
||||||
|
await page.getByTestId("phase-voting").click();
|
||||||
|
await expect(page.getByTestId("phase-voting")).toHaveClass(/on/);
|
||||||
|
await expect(page.getByTestId("phase-writing")).not.toHaveClass(/on/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { test, expect, type Page, type Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
async function createBoard(page: Page, title: string) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("board-title").fill(title);
|
||||||
|
await page.getByTestId("template").selectOption("mad-sad-glad");
|
||||||
|
await page.getByTestId("create").click();
|
||||||
|
await page.waitForURL(/\/b\/.+/);
|
||||||
|
return page.url();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinAs(page: Page, boardUrl: string, name: string) {
|
||||||
|
await page.goto(boardUrl);
|
||||||
|
await page.getByTestId("name").fill(name);
|
||||||
|
await page.getByTestId("join").click();
|
||||||
|
await expect(page.getByTestId("column").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escribe una nota privada y la publica (queda en el área pública).
|
||||||
|
// Esperamos el conteo de notas privadas para evitar clics sobre elementos que re-renderizan.
|
||||||
|
async function addPublicNote(col: Locator, text: string) {
|
||||||
|
await col.getByTestId("note-input").fill(text);
|
||||||
|
await col.getByTestId("note-input").press("Enter");
|
||||||
|
await expect(col.getByTestId("private-note")).toHaveCount(1);
|
||||||
|
await col.getByTestId("private-note").getByTestId("publish").click();
|
||||||
|
await expect(col.getByTestId("private-note")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrastra una nota (desde su tirador) sobre el centro de otra (gesto de agrupar).
|
||||||
|
async function dragOnto(page: Page, sourceNote: Locator, target: Locator) {
|
||||||
|
const handle = await sourceNote.getByTestId("note-handle").boundingBox();
|
||||||
|
const t = await target.boundingBox();
|
||||||
|
if (!handle || !t) throw new Error("sin boundingBox");
|
||||||
|
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2 + 8); // supera el umbral
|
||||||
|
await page.mouse.move(t.x + t.width / 2, t.y + t.height / 2, { steps: 12 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("agrupar dos notas arrastrando una sobre otra", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Retro grupos");
|
||||||
|
await joinAs(page, url, "Ana");
|
||||||
|
|
||||||
|
const col = page.getByTestId("column").first();
|
||||||
|
await addPublicNote(col, "primera");
|
||||||
|
await addPublicNote(col, "segunda");
|
||||||
|
await expect(col.getByTestId("note")).toHaveCount(2);
|
||||||
|
|
||||||
|
const notes = col.getByTestId("note");
|
||||||
|
await dragOnto(page, notes.nth(1), notes.nth(0));
|
||||||
|
|
||||||
|
// aparece un grupo con dos notas dentro
|
||||||
|
await expect(page.getByTestId("group")).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId("group").getByTestId("note")).toHaveCount(2);
|
||||||
|
|
||||||
|
// votar el grupo como unidad
|
||||||
|
await page.getByTestId("group").getByTestId("vote").click();
|
||||||
|
await expect(page.getByTestId("group").getByTestId("vote")).toHaveClass(/voted/);
|
||||||
|
|
||||||
|
// y nombrarlo
|
||||||
|
await page.getByTestId("group-title").fill("Tema común");
|
||||||
|
await page.getByTestId("group-title").blur();
|
||||||
|
await expect(page.getByTestId("group-title")).toHaveValue("Tema común");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multivoto: apilar varios votos en el mismo ítem y quitar", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Retro multivoto");
|
||||||
|
await joinAs(page, url, "Ana");
|
||||||
|
|
||||||
|
const col = page.getByTestId("column").first();
|
||||||
|
await addPublicNote(col, "favorita");
|
||||||
|
await expect(col.getByTestId("note")).toHaveCount(1);
|
||||||
|
|
||||||
|
// dos votos al mismo ítem (multivoto activo por defecto)
|
||||||
|
await page.getByTestId("vote").click();
|
||||||
|
await page.getByTestId("vote").click();
|
||||||
|
|
||||||
|
// el moderador revela los totales -> debe verse 2
|
||||||
|
await page.getByTestId("reveal-votes").click();
|
||||||
|
await expect(page.getByTestId("vote")).toContainText("2");
|
||||||
|
|
||||||
|
// quitar uno -> 1
|
||||||
|
await page.getByTestId("vote-remove").click();
|
||||||
|
await expect(page.getByTestId("vote")).toContainText("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el moderador cambia los ajustes en caliente (título y votos)", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Original");
|
||||||
|
await joinAs(page, url, "Mod");
|
||||||
|
|
||||||
|
await page.getByTestId("settings").click();
|
||||||
|
await page.getByTestId("settings-title").fill("Renombrada en vivo");
|
||||||
|
await page.getByTestId("settings-votes").fill("9");
|
||||||
|
await page.getByTestId("settings-save").click();
|
||||||
|
|
||||||
|
await expect(page.locator("header h1")).toHaveText("Renombrada en vivo");
|
||||||
|
await expect(page.locator(".votes-pill")).toContainText("9");
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function createBoard(page: Page, title: string) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("board-title").fill(title);
|
||||||
|
await page.getByTestId("template").selectOption("mad-sad-glad");
|
||||||
|
await page.getByTestId("create").click();
|
||||||
|
await page.waitForURL(/\/b\/.+/);
|
||||||
|
return page.url();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinAs(page: Page, boardUrl: string, name: string) {
|
||||||
|
await page.goto(boardUrl);
|
||||||
|
await page.getByTestId("name").fill(name);
|
||||||
|
await page.getByTestId("join").click();
|
||||||
|
await expect(page.getByTestId("column").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("exportar a JSON e importar: estructura vs todo", async ({ page, request }) => {
|
||||||
|
const url = await createBoard(page, "Fuente");
|
||||||
|
const boardId = url.split("/b/")[1];
|
||||||
|
await joinAs(page, url, "Origen");
|
||||||
|
|
||||||
|
const col = page.getByTestId("column").first();
|
||||||
|
await col.getByTestId("note-input").fill("nota a exportar");
|
||||||
|
await col.getByTestId("note-input").press("Enter");
|
||||||
|
await col.getByTestId("publish").first().click(); // publicada para que viaje visible
|
||||||
|
await expect(col.getByTestId("note")).toHaveCount(1);
|
||||||
|
|
||||||
|
const snapshotJson = await (
|
||||||
|
await request.get(`http://localhost:3001/api/boards/${boardId}/export.json`)
|
||||||
|
).text();
|
||||||
|
const filePayload = { name: "fuente.retro.json", mimeType: "application/json", buffer: Buffer.from(snapshotJson) };
|
||||||
|
|
||||||
|
// --- import solo estructura: 3 columnas, 0 notas ---
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("import-toggle").click();
|
||||||
|
await page.getByText("Only structure", { exact: false }).click();
|
||||||
|
await page.getByTestId("import-file").setInputFiles(filePayload);
|
||||||
|
await page.waitForURL(/\/b\/.+/);
|
||||||
|
await joinAs(page, page.url(), "Importador");
|
||||||
|
await expect(page.getByTestId("column")).toHaveCount(3);
|
||||||
|
await expect(page.getByTestId("note")).toHaveCount(0);
|
||||||
|
|
||||||
|
// --- import completo: la nota viaja ---
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("import-toggle").click();
|
||||||
|
await page.getByTestId("import-file").setInputFiles(filePayload); // modo 'full' por defecto
|
||||||
|
await page.waitForURL(/\/b\/.+/);
|
||||||
|
await joinAs(page, page.url(), "Importador2");
|
||||||
|
await expect(page.getByTestId("note")).toHaveCount(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { test, expect, type Page, type Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
async function createBoard(page: Page, title: string) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("board-title").fill(title);
|
||||||
|
await page.getByTestId("template").selectOption("mad-sad-glad");
|
||||||
|
await page.getByTestId("create").click();
|
||||||
|
await page.waitForURL(/\/b\/.+/);
|
||||||
|
return page.url();
|
||||||
|
}
|
||||||
|
async function joinAs(page: Page, url: string, name: string) {
|
||||||
|
await page.goto(url);
|
||||||
|
await page.getByTestId("name").fill(name);
|
||||||
|
await page.getByTestId("join").click();
|
||||||
|
await expect(page.getByTestId("column").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
async function addPublicNote(col: Locator, text: string) {
|
||||||
|
await col.getByTestId("note-input").fill(text);
|
||||||
|
await col.getByTestId("note-input").press("Enter");
|
||||||
|
await expect(col.getByTestId("private-note")).toHaveCount(1);
|
||||||
|
await col.getByTestId("private-note").getByTestId("publish").click();
|
||||||
|
await expect(col.getByTestId("private-note")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("participantes: presencia, editar nombre y mostrar autores", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Retro panel");
|
||||||
|
await joinAs(page, url, "Ana");
|
||||||
|
const col = page.getByTestId("column").first();
|
||||||
|
await addPublicNote(col, "hola");
|
||||||
|
await expect(col.locator(".note .author")).toHaveText("Ana");
|
||||||
|
|
||||||
|
await page.getByTestId("open-participants").click();
|
||||||
|
const people = page.locator(".people");
|
||||||
|
await expect(people).toContainText("Ana");
|
||||||
|
|
||||||
|
// editar mi nombre
|
||||||
|
await page.getByTestId("edit-name").click();
|
||||||
|
await page.getByTestId("name-input").fill("Anita");
|
||||||
|
await page.getByTestId("name-save").click();
|
||||||
|
await expect(people).toContainText("Anita");
|
||||||
|
|
||||||
|
// ocultar autores de las tarjetas (checkbox controlado por el servidor -> click)
|
||||||
|
await page.getByTestId("show-creators").click();
|
||||||
|
await page.mouse.click(20, 400); // cerrar el drawer pulsando en el backdrop
|
||||||
|
await expect(col.locator(".note .author")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("action points: añadir y mover a Done", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Retro acciones");
|
||||||
|
await joinAs(page, url, "Ana");
|
||||||
|
|
||||||
|
await page.getByTestId("open-actions").click();
|
||||||
|
await page.getByTestId("action-input").fill("documentar el deploy");
|
||||||
|
await page.getByTestId("action-add").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("todo-list").getByTestId("action-row")).toHaveCount(1);
|
||||||
|
|
||||||
|
// marcar como hecho -> la fila se mueve a Done (la casilla se desmonta del To-do)
|
||||||
|
await page.getByTestId("todo-list").getByTestId("action-done").first().click();
|
||||||
|
await expect(page.getByTestId("todo-list").getByTestId("action-row")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("done-list").getByTestId("action-row")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export modal: previsualización con opciones", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Retro export");
|
||||||
|
await joinAs(page, url, "Ana");
|
||||||
|
const col = page.getByTestId("column").first();
|
||||||
|
await addPublicNote(col, "algo");
|
||||||
|
|
||||||
|
await page.getByTestId("settings").click();
|
||||||
|
await page.getByTestId("open-export").click();
|
||||||
|
const preview = page.getByTestId("export-preview");
|
||||||
|
await expect(preview).toContainText("Retro export");
|
||||||
|
await expect(preview).toContainText("algo");
|
||||||
|
|
||||||
|
// desactivar autores -> desaparece el nombre del preview
|
||||||
|
await expect(preview).toContainText("_Ana_");
|
||||||
|
await page.getByTestId("opt-authors").uncheck();
|
||||||
|
await expect(preview).not.toContainText("_Ana_");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opciones: bloquear deja el board en solo lectura", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Retro lock");
|
||||||
|
await joinAs(page, url, "Ana");
|
||||||
|
|
||||||
|
await page.getByTestId("settings").click();
|
||||||
|
await page.getByTestId("opt-lock").click();
|
||||||
|
await page.mouse.click(20, 400); // cerrar el panel
|
||||||
|
|
||||||
|
await expect(page.locator(".locked-banner")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("private-section")).toHaveCount(0); // sin sección privada al bloquear
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { test, expect, type Page, type Locator } from "@playwright/test";
|
||||||
|
|
||||||
|
async function createBoard(page: Page, title: string, template = "mad-sad-glad") {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("board-title").fill(title);
|
||||||
|
await page.getByTestId("template").selectOption(template);
|
||||||
|
await page.getByTestId("create").click();
|
||||||
|
await page.waitForURL(/\/b\/.+/);
|
||||||
|
return page.url();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinAs(page: Page, boardUrl: string, name: string) {
|
||||||
|
await page.goto(boardUrl);
|
||||||
|
await expect(page.getByTestId("name")).toBeVisible();
|
||||||
|
await page.getByTestId("name").fill(name);
|
||||||
|
await page.getByTestId("join").click();
|
||||||
|
await expect(page.getByTestId("column").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escribe una nota en la sección privada de una columna (se guarda con Enter).
|
||||||
|
async function writeNote(col: Locator, text: string) {
|
||||||
|
await col.getByTestId("note-input").fill(text);
|
||||||
|
await col.getByTestId("note-input").press("Enter");
|
||||||
|
await expect(col.getByTestId("private-note").last()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("crea un board, pide nombre obligatorio y muestra columnas", async ({ page }) => {
|
||||||
|
await createBoard(page, "Sprint 42");
|
||||||
|
await expect(page.getByTestId("name")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("column")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("name").fill("Fede");
|
||||||
|
await page.getByTestId("join").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("column")).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una nota privada no la ve nadie hasta publicarla", async ({ browser }) => {
|
||||||
|
const ctxA = await browser.newContext();
|
||||||
|
const ctxB = await browser.newContext();
|
||||||
|
const alice = await ctxA.newPage();
|
||||||
|
const bob = await ctxB.newPage();
|
||||||
|
|
||||||
|
const url = await createBoard(alice, "Retro privada");
|
||||||
|
await joinAs(alice, url, "Alice");
|
||||||
|
await joinAs(bob, url, "Bob");
|
||||||
|
|
||||||
|
const aliceCol = alice.getByTestId("column").first();
|
||||||
|
await writeNote(aliceCol, "algo privado");
|
||||||
|
|
||||||
|
// Bob no ve nada: ni nota pública ni la privada de Alice
|
||||||
|
await expect(bob.getByTestId("note")).toHaveCount(0);
|
||||||
|
await expect(bob.getByTestId("private-note")).toHaveCount(0);
|
||||||
|
|
||||||
|
// Alice publica su nota
|
||||||
|
await aliceCol.getByTestId("publish").first().click();
|
||||||
|
|
||||||
|
// ahora es pública para Bob (la ve como párrafo de solo lectura)
|
||||||
|
await expect(bob.getByTestId("note")).toHaveCount(1);
|
||||||
|
await expect(bob.getByTestId("note-text")).toHaveText("algo privado");
|
||||||
|
|
||||||
|
await ctxA.close();
|
||||||
|
await ctxB.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Publish all publica todas mis notas de la columna", async ({ page }) => {
|
||||||
|
const url = await createBoard(page, "Retro publish all");
|
||||||
|
await joinAs(page, url, "Vera");
|
||||||
|
|
||||||
|
const col = page.getByTestId("column").first();
|
||||||
|
await writeNote(col, "una");
|
||||||
|
await writeNote(col, "dos");
|
||||||
|
await expect(col.getByTestId("private-note")).toHaveCount(2);
|
||||||
|
|
||||||
|
await col.getByTestId("publish-all").click();
|
||||||
|
|
||||||
|
await expect(col.getByTestId("private-note")).toHaveCount(0);
|
||||||
|
await expect(col.getByTestId("note")).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("votar una nota publicada y exportar markdown con el conteo", async ({ page, request }) => {
|
||||||
|
const url = await createBoard(page, "Retro votos");
|
||||||
|
const boardId = url.split("/b/")[1];
|
||||||
|
await joinAs(page, url, "Vera");
|
||||||
|
|
||||||
|
const col = page.getByTestId("column").first();
|
||||||
|
await writeNote(col, "idea top");
|
||||||
|
await col.getByTestId("publish").first().click();
|
||||||
|
await expect(page.getByTestId("note")).toHaveCount(1);
|
||||||
|
|
||||||
|
// votar: con totales ocultos el botón marca "✓" (mi voto), no el total
|
||||||
|
await page.getByTestId("vote").first().click();
|
||||||
|
await expect(page.getByTestId("vote").first()).toHaveClass(/voted/);
|
||||||
|
|
||||||
|
// el moderador revela los votos -> ahora se ve el total
|
||||||
|
await page.getByTestId("reveal-votes").click();
|
||||||
|
await expect(page.getByTestId("vote").first()).toContainText("1");
|
||||||
|
|
||||||
|
const res = await request.get(`http://localhost:3001/api/boards/${boardId}/export`);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const md = await res.text();
|
||||||
|
expect(md).toContain("# Retro votos");
|
||||||
|
expect(md).toContain("idea top — _Vera_ (1 👍)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el timer compartido arranca para todos los participantes", async ({ browser }) => {
|
||||||
|
const ctxA = await browser.newContext();
|
||||||
|
const ctxB = await browser.newContext();
|
||||||
|
const alice = await ctxA.newPage();
|
||||||
|
const bob = await ctxB.newPage();
|
||||||
|
|
||||||
|
const url = await createBoard(alice, "Retro timer");
|
||||||
|
await joinAs(alice, url, "Alice");
|
||||||
|
await joinAs(bob, url, "Bob");
|
||||||
|
|
||||||
|
await alice.getByRole("button", { name: /5\s*min/i }).click();
|
||||||
|
|
||||||
|
await expect(alice.locator(".timer .clock")).toHaveText(/0[0-4]:\d{2}/);
|
||||||
|
await expect(bob.locator(".timer .clock")).toHaveText(/0[0-4]:\d{2}/);
|
||||||
|
|
||||||
|
await ctxA.close();
|
||||||
|
await ctxB.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL=postgres://retro:retro@localhost:5433/retro
|
||||||
|
PORT=3001
|
||||||
|
WEB_ORIGIN=http://localhost:5173
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro",
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
Generated
+4426
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "sternboard-server",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"serve": "tsx src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/static": "^8.3.0",
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
|
"fastify": "^5.1.0",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"postgres": "^3.4.5",
|
||||||
|
"socket.io": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-kit": "^0.28.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import { lt, isNotNull, and } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { db, schema } from "./db/index.js";
|
||||||
|
import { TEMPLATES, getTemplate } from "./templates.js";
|
||||||
|
import { registerSockets } from "./socket.js";
|
||||||
|
import { loadBoardState } from "./room.js";
|
||||||
|
import { boardToMarkdown, DEFAULT_MD_OPTIONS } from "./markdown.js";
|
||||||
|
|
||||||
|
export type BuiltServer = {
|
||||||
|
app: FastifyInstance;
|
||||||
|
io: Server;
|
||||||
|
listen: (port: number) => Promise<number>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildServer(opts: { webOrigin?: string; cleanup?: boolean } = {}): BuiltServer {
|
||||||
|
const webOrigin = opts.webOrigin ?? process.env.WEB_ORIGIN ?? "http://localhost:5173";
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
app.register(cors, { origin: webOrigin });
|
||||||
|
|
||||||
|
app.get("/api/templates", async () => TEMPLATES);
|
||||||
|
|
||||||
|
app.post("/api/boards", async (req, reply) => {
|
||||||
|
const body = req.body as {
|
||||||
|
title?: string;
|
||||||
|
templateId?: string;
|
||||||
|
columns?: string[];
|
||||||
|
votesPerUser?: number;
|
||||||
|
ttlHours?: number | null;
|
||||||
|
};
|
||||||
|
const title = (body.title ?? "").trim() || "Retro";
|
||||||
|
const votesPerUser = clampInt(body.votesPerUser ?? 3, 0, 50);
|
||||||
|
|
||||||
|
let columnTitles: string[] = [];
|
||||||
|
if (body.templateId) {
|
||||||
|
const tpl = getTemplate(body.templateId);
|
||||||
|
if (!tpl) return reply.code(400).send({ error: "template_not_found" });
|
||||||
|
columnTitles = tpl.columns;
|
||||||
|
} else if (body.columns && body.columns.length > 0) {
|
||||||
|
columnTitles = body.columns.map((c) => c.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (columnTitles.length === 0) columnTitles = ["Columna 1", "Columna 2", "Columna 3"];
|
||||||
|
|
||||||
|
const id = nanoid(12);
|
||||||
|
const moderatorToken = nanoid(24);
|
||||||
|
const expiresAt =
|
||||||
|
body.ttlHours && body.ttlHours > 0 ? new Date(Date.now() + body.ttlHours * 3600 * 1000) : null;
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(schema.boards).values({ id, title, votesPerUser, moderatorToken, expiresAt });
|
||||||
|
await tx
|
||||||
|
.insert(schema.columns)
|
||||||
|
.values(columnTitles.map((t, i) => ({ id: nanoid(), boardId: id, title: t, position: i })));
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({ boardId: id, moderatorToken, expiresAt: expiresAt?.toISOString() ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/boards/:id/export", async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const state = await loadBoardState(id);
|
||||||
|
if (!state) return reply.code(404).send({ error: "board_not_found" });
|
||||||
|
const q = req.query as Record<string, string | undefined>;
|
||||||
|
// un parámetro presente y != '0'/'false' cuenta como activado; si no se pasa ninguno, defaults
|
||||||
|
const on = (v: string | undefined, dflt: boolean) => (v === undefined ? dflt : v !== "0" && v !== "false");
|
||||||
|
const md = boardToMarkdown(state, {
|
||||||
|
authors: on(q.authors, DEFAULT_MD_OPTIONS.authors),
|
||||||
|
votes: on(q.votes, DEFAULT_MD_OPTIONS.votes),
|
||||||
|
expandGroups: on(q.groups, DEFAULT_MD_OPTIONS.expandGroups),
|
||||||
|
creationDate: on(q.created, DEFAULT_MD_OPTIONS.creationDate),
|
||||||
|
exportDate: on(q.exported, DEFAULT_MD_OPTIONS.exportDate),
|
||||||
|
});
|
||||||
|
// ?download=1 fuerza descarga; si no, texto inline (para la previsualización)
|
||||||
|
if (q.download && q.download !== "0") {
|
||||||
|
reply.header("Content-Disposition", `attachment; filename="${slugify(state.title)}.md"`);
|
||||||
|
}
|
||||||
|
reply.header("Content-Type", "text/markdown; charset=utf-8").send(md);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/boards/:id", async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const state = await loadBoardState(id);
|
||||||
|
if (!state) return reply.code(404).send({ error: "board_not_found" });
|
||||||
|
return reply.send({ id: state.id, title: state.title, expiresAt: state.expiresAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export completo del board a JSON (config + columnas + grupos + notas + votos + acciones).
|
||||||
|
app.get("/api/boards/:id/export.json", async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const state = await loadBoardState(id);
|
||||||
|
if (!state) return reply.code(404).send({ error: "board_not_found" });
|
||||||
|
const snapshot = {
|
||||||
|
version: 1 as const,
|
||||||
|
board: { title: state.title, votesPerUser: state.votesPerUser, allowMultiVote: state.allowMultiVote },
|
||||||
|
columns: state.columns,
|
||||||
|
groups: state.groups,
|
||||||
|
notes: state.notes,
|
||||||
|
votes: state.votes,
|
||||||
|
actionItems: state.actionItems.map((a) => ({ text: a.text, owner: a.owner })),
|
||||||
|
};
|
||||||
|
reply
|
||||||
|
.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
.header("Content-Disposition", `attachment; filename="${slugify(state.title)}.retro.json"`)
|
||||||
|
.send(JSON.stringify(snapshot, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import: crea un board NUEVO desde un snapshot. mode 'full' (todo) o 'structure' (solo columnas+config).
|
||||||
|
app.post("/api/boards/import", async (req, reply) => {
|
||||||
|
const body = req.body as {
|
||||||
|
snapshot?: ImportSnapshot;
|
||||||
|
mode?: "full" | "structure";
|
||||||
|
title?: string;
|
||||||
|
ttlHours?: number | null;
|
||||||
|
};
|
||||||
|
const snap = body.snapshot;
|
||||||
|
const mode = body.mode === "structure" ? "structure" : "full";
|
||||||
|
if (!snap || !Array.isArray(snap.columns) || snap.columns.length === 0) {
|
||||||
|
return reply.code(400).send({ error: "invalid_snapshot" });
|
||||||
|
}
|
||||||
|
const result = await importBoard(snap, mode, { title: body.title, ttlHours: body.ttlHours });
|
||||||
|
return reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// En producción servimos el front estático (build de Vite) desde el mismo servidor,
|
||||||
|
// con fallback SPA: cualquier ruta que no sea /api ni /socket.io devuelve index.html.
|
||||||
|
if (process.env.WEB_DIST) {
|
||||||
|
app.register(fastifyStatic, { root: process.env.WEB_DIST, prefix: "/" });
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
if (req.url.startsWith("/api") || req.url.startsWith("/socket.io")) {
|
||||||
|
return reply.code(404).send({ error: "not_found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const io = new Server(app.server, { cors: { origin: webOrigin } });
|
||||||
|
registerSockets(io);
|
||||||
|
|
||||||
|
if (opts.cleanup) {
|
||||||
|
cleanupTimer = setInterval(() => runCleanup(io).catch(() => {}), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
io,
|
||||||
|
listen: async (port: number) => {
|
||||||
|
await app.listen({ port, host: "0.0.0.0" });
|
||||||
|
const addr = app.server.address();
|
||||||
|
return typeof addr === "object" && addr ? addr.port : port;
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
if (cleanupTimer) clearInterval(cleanupTimer);
|
||||||
|
io.close();
|
||||||
|
await app.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Borra boards caducados y avisa a sus rooms. Exportado para poder testearlo.
|
||||||
|
export async function runCleanup(io: Server): Promise<string[]> {
|
||||||
|
const expired = await db
|
||||||
|
.delete(schema.boards)
|
||||||
|
.where(and(isNotNull(schema.boards.expiresAt), lt(schema.boards.expiresAt, new Date())))
|
||||||
|
.returning({ id: schema.boards.id });
|
||||||
|
for (const b of expired) io.to(b.id).emit("board:destroyed");
|
||||||
|
return expired.map((b) => b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(n: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, Math.round(n)));
|
||||||
|
}
|
||||||
|
function slugify(s: string) {
|
||||||
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "retro";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportSnapshot = {
|
||||||
|
version: number;
|
||||||
|
board: { title: string; votesPerUser: number; allowMultiVote?: boolean };
|
||||||
|
columns: { id: string; title: string; position: number }[];
|
||||||
|
groups: { id: string; columnId: string; title: string; position: number }[];
|
||||||
|
notes: {
|
||||||
|
id: string;
|
||||||
|
columnId: string;
|
||||||
|
groupId: string | null;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
text: string;
|
||||||
|
color?: string | null;
|
||||||
|
revealed: boolean;
|
||||||
|
position: number;
|
||||||
|
}[];
|
||||||
|
votes: { targetType: string; targetId: string; participantId: string; count?: number }[];
|
||||||
|
actionItems: { text: string; owner: string | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crea un board nuevo desde un snapshot, remapeando todos los ids.
|
||||||
|
// 'structure' importa solo columnas + config; 'full' importa todo el contenido.
|
||||||
|
export async function importBoard(
|
||||||
|
snap: ImportSnapshot,
|
||||||
|
mode: "full" | "structure",
|
||||||
|
opts: { title?: string; ttlHours?: number | null },
|
||||||
|
): Promise<{ boardId: string; moderatorToken: string; expiresAt: string | null }> {
|
||||||
|
const id = nanoid(12);
|
||||||
|
const moderatorToken = nanoid(24);
|
||||||
|
const title = (opts.title ?? snap.board?.title ?? "Retro").trim() || "Retro";
|
||||||
|
const votesPerUser = clampInt(snap.board?.votesPerUser ?? 3, 0, 50);
|
||||||
|
const allowMultiVote = snap.board?.allowMultiVote ?? true;
|
||||||
|
const expiresAt =
|
||||||
|
opts.ttlHours && opts.ttlHours > 0 ? new Date(Date.now() + opts.ttlHours * 3600 * 1000) : null;
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(schema.boards).values({ id, title, votesPerUser, allowMultiVote, moderatorToken, expiresAt });
|
||||||
|
|
||||||
|
const colMap = new Map<string, string>();
|
||||||
|
for (const c of snap.columns) {
|
||||||
|
const newId = nanoid();
|
||||||
|
colMap.set(c.id, newId);
|
||||||
|
await tx.insert(schema.columns).values({ id: newId, boardId: id, title: c.title, position: c.position });
|
||||||
|
}
|
||||||
|
if (mode === "structure") return;
|
||||||
|
|
||||||
|
const groupMap = new Map<string, string>();
|
||||||
|
for (const g of snap.groups ?? []) {
|
||||||
|
const colId = colMap.get(g.columnId);
|
||||||
|
if (!colId) continue;
|
||||||
|
const newId = nanoid();
|
||||||
|
groupMap.set(g.id, newId);
|
||||||
|
await tx
|
||||||
|
.insert(schema.groups)
|
||||||
|
.values({ id: newId, boardId: id, columnId: colId, title: g.title, position: g.position });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMap = new Map<string, string>(); // viejo id (nota/grupo) -> nuevo id, para los votos
|
||||||
|
for (const [oldG, newG] of groupMap) targetMap.set(oldG, newG);
|
||||||
|
for (const n of snap.notes ?? []) {
|
||||||
|
const colId = colMap.get(n.columnId);
|
||||||
|
if (!colId) continue;
|
||||||
|
const newId = nanoid();
|
||||||
|
targetMap.set(n.id, newId);
|
||||||
|
await tx.insert(schema.notes).values({
|
||||||
|
id: newId,
|
||||||
|
boardId: id,
|
||||||
|
columnId: colId,
|
||||||
|
groupId: n.groupId ? groupMap.get(n.groupId) ?? null : null,
|
||||||
|
authorId: n.authorId,
|
||||||
|
authorName: n.authorName,
|
||||||
|
text: n.text,
|
||||||
|
color: n.color ?? null,
|
||||||
|
revealed: n.revealed,
|
||||||
|
position: n.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of snap.votes ?? []) {
|
||||||
|
const targetId = targetMap.get(v.targetId);
|
||||||
|
if (!targetId) continue;
|
||||||
|
await tx.insert(schema.votes).values({
|
||||||
|
boardId: id,
|
||||||
|
targetType: v.targetType,
|
||||||
|
targetId,
|
||||||
|
participantId: v.participantId,
|
||||||
|
count: v.count ?? 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of snap.actionItems ?? []) {
|
||||||
|
await tx.insert(schema.actionItems).values({ id: nanoid(), boardId: id, text: a.text, owner: a.owner });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { boardId: id, moderatorToken, expiresAt: expiresAt?.toISOString() ?? null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) throw new Error("DATABASE_URL no definido");
|
||||||
|
|
||||||
|
// max alto: las transacciones de voto usan FOR UPDATE y, bajo ráfagas concurrentes,
|
||||||
|
// no deben quedarse sin conexión (evita timeouts esporádicos).
|
||||||
|
const client = postgres(url, { max: 20 });
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
export { schema };
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
doublePrecision,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const boards = pgTable("boards", {
|
||||||
|
id: text("id").primaryKey(), // slug no adivinable
|
||||||
|
title: text("title").notNull(),
|
||||||
|
phase: text("phase").notNull().default("writing"), // writing | revealed | voting | done
|
||||||
|
revealed: boolean("revealed").notNull().default(false), // moderador revela TODAS las notas
|
||||||
|
votesRevealed: boolean("votes_revealed").notNull().default(false), // moderador revela los totales
|
||||||
|
votesPerUser: integer("votes_per_user").notNull().default(3),
|
||||||
|
allowMultiVote: boolean("allow_multi_vote").notNull().default(true), // varios votos al mismo ítem
|
||||||
|
moderationMode: text("moderation_mode").notNull().default("everyone"), // 'everyone' | 'single'
|
||||||
|
showCardCreators: boolean("show_card_creators").notNull().default(true),
|
||||||
|
locked: boolean("locked").notNull().default(false), // solo lectura
|
||||||
|
timerEndsAt: timestamp("timer_ends_at", { withTimezone: true }),
|
||||||
|
timerRunning: boolean("timer_running").notNull().default(false),
|
||||||
|
moderatorToken: text("moderator_token").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }), // autodestrucción; null = sin caducidad
|
||||||
|
});
|
||||||
|
|
||||||
|
export const columns = pgTable("columns", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
boardId: text("board_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
position: integer("position").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un grupo (cluster) de notas dentro de una columna. Es la unidad votable.
|
||||||
|
export const groups = pgTable("groups", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
boardId: text("board_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
|
columnId: text("column_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => columns.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull().default(""),
|
||||||
|
position: doublePrecision("position").notNull().default(0), // fractional indexing
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notes = pgTable("notes", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
boardId: text("board_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
|
columnId: text("column_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => columns.id, { onDelete: "cascade" }),
|
||||||
|
groupId: text("group_id").references(() => groups.id, { onDelete: "set null" }), // null = suelta
|
||||||
|
authorId: text("author_id").notNull(),
|
||||||
|
authorName: text("author_name").notNull(),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
color: text("color"), // color del post-it (hex); null = color por defecto
|
||||||
|
revealed: boolean("revealed").notNull().default(false), // el autor revela su propia nota
|
||||||
|
position: doublePrecision("position").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Voto sobre un votable genérico: una nota suelta o un grupo.
|
||||||
|
export const votes = pgTable(
|
||||||
|
"votes",
|
||||||
|
{
|
||||||
|
boardId: text("board_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
|
targetType: text("target_type").notNull(), // 'note' | 'group'
|
||||||
|
targetId: text("target_id").notNull(),
|
||||||
|
participantId: text("participant_id").notNull(),
|
||||||
|
count: integer("count").notNull().default(1), // nº de votos de este participante a este ítem
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniq: uniqueIndex("votes_target_participant_uniq").on(t.targetId, t.participantId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const actionItems = pgTable("action_items", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
boardId: text("board_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
owner: text("owner"),
|
||||||
|
done: boolean("done").notNull().default(false),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Carga .env (cargador nativo de Node) salvo que las vars ya vengan del entorno
|
||||||
|
// — así Playwright/CI pueden inyectar otra DATABASE_URL sin que el fichero la pise.
|
||||||
|
// Importar este módulo el PRIMERO de todos por su efecto secundario.
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
try {
|
||||||
|
process.loadEnvFile();
|
||||||
|
} catch {
|
||||||
|
// sin .env: se usarán las variables del entorno
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import "./env.js"; // primero: carga .env antes de tocar la DB
|
||||||
|
import { buildServer } from "./app.js";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 3001);
|
||||||
|
const server = buildServer({ cleanup: true });
|
||||||
|
await server.listen(PORT);
|
||||||
|
console.log(`Sternboard server escuchando en :${PORT}`);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { BoardState } from "./room.js";
|
||||||
|
|
||||||
|
export type MarkdownOptions = {
|
||||||
|
authors: boolean; // mostrar nombres de autor
|
||||||
|
votes: boolean; // mostrar conteo de votos
|
||||||
|
expandGroups: boolean; // listar las notas dentro de cada grupo
|
||||||
|
creationDate: boolean; // añadir la fecha de creación al título
|
||||||
|
exportDate: boolean; // añadir línea con la fecha de exportación
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MD_OPTIONS: MarkdownOptions = {
|
||||||
|
authors: true,
|
||||||
|
votes: true,
|
||||||
|
expandGroups: true,
|
||||||
|
creationDate: false,
|
||||||
|
exportDate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDate(d: Date): string {
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export del estado del board a Markdown, con opciones (grupos, autores, votos, fechas).
|
||||||
|
export function boardToMarkdown(
|
||||||
|
state: BoardState,
|
||||||
|
opts: MarkdownOptions = DEFAULT_MD_OPTIONS,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const created = opts.creationDate ? `${fmtDate(new Date(state.createdAt))} - ` : "";
|
||||||
|
lines.push(`# ${created}${state.title}`);
|
||||||
|
if (opts.exportDate) lines.push(`_Exported: ${fmtDate(now)}_`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const votesOf = (targetId: string) =>
|
||||||
|
state.votes.filter((v) => v.targetId === targetId).reduce((sum, v) => sum + v.count, 0);
|
||||||
|
const votePart = (n: number) => (opts.votes && n > 0 ? ` (${n} 👍)` : "");
|
||||||
|
const authorPart = (name: string) => (opts.authors ? ` — _${name}_` : "");
|
||||||
|
|
||||||
|
for (const col of state.columns) {
|
||||||
|
lines.push(`## ${col.title}`);
|
||||||
|
|
||||||
|
const groups = state.groups.filter((g) => g.columnId === col.id);
|
||||||
|
const standalone = state.notes.filter((n) => n.columnId === col.id && !n.groupId);
|
||||||
|
|
||||||
|
type Item = { votes: number; render: () => string[] };
|
||||||
|
const items: Item[] = [];
|
||||||
|
|
||||||
|
for (const g of groups) {
|
||||||
|
const members = state.notes.filter((n) => n.groupId === g.id);
|
||||||
|
const v = votesOf(g.id);
|
||||||
|
items.push({
|
||||||
|
votes: v,
|
||||||
|
render: () => {
|
||||||
|
const out = [`- **${g.title?.trim() || "Grupo"}**${votePart(v)}`];
|
||||||
|
if (opts.expandGroups) for (const m of members) out.push(` - ${m.text}${authorPart(m.authorName)}`);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const n of standalone) {
|
||||||
|
const v = votesOf(n.id);
|
||||||
|
items.push({ votes: v, render: () => [`- ${n.text}${authorPart(n.authorName)}${votePart(v)}`] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
lines.push("_(sin notas)_");
|
||||||
|
} else {
|
||||||
|
items.sort((a, b) => b.votes - a.votes);
|
||||||
|
for (const it of items) lines.push(...it.render());
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.actionItems.length > 0) {
|
||||||
|
lines.push("## 📋 Action items");
|
||||||
|
for (const a of state.actionItems) {
|
||||||
|
const owner = a.owner ? ` — **${a.owner}**` : "";
|
||||||
|
lines.push(`- [${a.done ? "x" : " "}] ${a.text}${owner}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db, schema } from "./db/index.js";
|
||||||
|
|
||||||
|
export type BoardState = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
phase: string;
|
||||||
|
revealed: boolean; // todas las notas reveladas (moderador)
|
||||||
|
votesRevealed: boolean; // totales de votos visibles (moderador)
|
||||||
|
votesPerUser: number;
|
||||||
|
allowMultiVote: boolean;
|
||||||
|
moderationMode: string; // 'everyone' | 'single'
|
||||||
|
showCardCreators: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
timerEndsAt: string | null;
|
||||||
|
timerRunning: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
youAreModerator?: boolean; // se rellena por-viewer al difundir
|
||||||
|
participants?: { id: string; name: string }[]; // presencia, se rellena al difundir
|
||||||
|
columns: { id: string; title: string; position: number }[];
|
||||||
|
groups: { id: string; columnId: string; title: string; position: number }[];
|
||||||
|
notes: {
|
||||||
|
id: string;
|
||||||
|
columnId: string;
|
||||||
|
groupId: string | null;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
text: string;
|
||||||
|
color: string | null;
|
||||||
|
revealed: boolean;
|
||||||
|
position: number;
|
||||||
|
}[];
|
||||||
|
votes: { targetType: string; targetId: string; participantId: string; count: number }[];
|
||||||
|
actionItems: { id: string; text: string; owner: string | null; done: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadBoardState(boardId: string): Promise<BoardState | null> {
|
||||||
|
const [board] = await db.select().from(schema.boards).where(eq(schema.boards.id, boardId));
|
||||||
|
if (!board) return null;
|
||||||
|
|
||||||
|
const [cols, grps, ns, vs, ais] = await Promise.all([
|
||||||
|
db.select().from(schema.columns).where(eq(schema.columns.boardId, boardId)),
|
||||||
|
db.select().from(schema.groups).where(eq(schema.groups.boardId, boardId)),
|
||||||
|
db.select().from(schema.notes).where(eq(schema.notes.boardId, boardId)),
|
||||||
|
db.select().from(schema.votes).where(eq(schema.votes.boardId, boardId)),
|
||||||
|
db.select().from(schema.actionItems).where(eq(schema.actionItems.boardId, boardId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: board.id,
|
||||||
|
title: board.title,
|
||||||
|
phase: board.phase,
|
||||||
|
revealed: board.revealed,
|
||||||
|
votesRevealed: board.votesRevealed,
|
||||||
|
votesPerUser: board.votesPerUser,
|
||||||
|
allowMultiVote: board.allowMultiVote,
|
||||||
|
moderationMode: board.moderationMode,
|
||||||
|
showCardCreators: board.showCardCreators,
|
||||||
|
locked: board.locked,
|
||||||
|
createdAt: board.createdAt.toISOString(),
|
||||||
|
timerEndsAt: board.timerEndsAt ? board.timerEndsAt.toISOString() : null,
|
||||||
|
timerRunning: board.timerRunning,
|
||||||
|
expiresAt: board.expiresAt ? board.expiresAt.toISOString() : null,
|
||||||
|
columns: cols
|
||||||
|
.map((c) => ({ id: c.id, title: c.title, position: c.position }))
|
||||||
|
.sort((a, b) => a.position - b.position),
|
||||||
|
groups: grps.map((g) => ({ id: g.id, columnId: g.columnId, title: g.title, position: g.position })),
|
||||||
|
notes: ns.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
columnId: n.columnId,
|
||||||
|
groupId: n.groupId,
|
||||||
|
authorId: n.authorId,
|
||||||
|
authorName: n.authorName,
|
||||||
|
text: n.text,
|
||||||
|
color: n.color,
|
||||||
|
revealed: n.revealed,
|
||||||
|
position: n.position,
|
||||||
|
})),
|
||||||
|
votes: vs.map((v) => ({
|
||||||
|
targetType: v.targetType,
|
||||||
|
targetId: v.targetId,
|
||||||
|
participantId: v.participantId,
|
||||||
|
count: v.count,
|
||||||
|
})),
|
||||||
|
actionItems: ais.map((a) => ({ id: a.id, text: a.text, owner: a.owner, done: a.done })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vista que recibe un participante concreto:
|
||||||
|
// - texto de notas ajenas oculto salvo que el board o la nota estén revelados.
|
||||||
|
// - votos: si los totales no están revelados, solo ve los suyos.
|
||||||
|
export function viewFor(state: BoardState, participantId: string): BoardState {
|
||||||
|
const notes = state.notes.map((n) => {
|
||||||
|
const visible = state.revealed || n.revealed || !!n.groupId || n.authorId === participantId;
|
||||||
|
return visible ? n : { ...n, text: "" };
|
||||||
|
});
|
||||||
|
const votes = state.votesRevealed
|
||||||
|
? state.votes
|
||||||
|
: state.votes.filter((v) => v.participantId === participantId);
|
||||||
|
return { ...state, notes, votes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import type { Server } from "socket.io";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { db, schema } from "./db/index.js";
|
||||||
|
import { loadBoardState, viewFor } from "./room.js";
|
||||||
|
|
||||||
|
type SessionData = { boardId: string; participantId: string; name: string; moderatorToken?: string };
|
||||||
|
|
||||||
|
// La sesión se guarda por socket.id para poder leerla también al difundir
|
||||||
|
// (fetchSockets devuelve sockets remotos sin acceso a closures).
|
||||||
|
const sessions = new Map<string, SessionData>();
|
||||||
|
|
||||||
|
export function registerSockets(io: Server) {
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
socket.on("disconnect", () => sessions.delete(socket.id));
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
"board:join",
|
||||||
|
async (p: { boardId: string; participantId: string; name: string; moderatorToken?: string }) => {
|
||||||
|
const state = await loadBoardState(p.boardId);
|
||||||
|
if (!state) return socket.emit("error", { message: "board_not_found" });
|
||||||
|
if (!p.name?.trim()) return socket.emit("error", { message: "name_required" });
|
||||||
|
sessions.set(socket.id, {
|
||||||
|
boardId: p.boardId,
|
||||||
|
participantId: p.participantId,
|
||||||
|
name: p.name.trim(),
|
||||||
|
moderatorToken: p.moderatorToken,
|
||||||
|
});
|
||||||
|
socket.join(p.boardId);
|
||||||
|
await broadcast(io, p.boardId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const guard = (): SessionData | null => {
|
||||||
|
const s = sessions.get(socket.id);
|
||||||
|
if (!s) {
|
||||||
|
socket.emit("error", { message: "not_joined" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// moderador si el board es abierto ('everyone') o el token coincide ('single')
|
||||||
|
const requireMod = async (boardId: string, token?: string) => {
|
||||||
|
const [board] = await db.select().from(schema.boards).where(eq(schema.boards.id, boardId));
|
||||||
|
if (!board) return false;
|
||||||
|
return board.moderationMode === "everyone" || board.moderatorToken === token;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ¿el board está bloqueado (solo lectura)? bloquea las mutaciones de contenido.
|
||||||
|
const isLocked = async (boardId: string) => {
|
||||||
|
const [board] = await db
|
||||||
|
.select({ locked: schema.boards.locked })
|
||||||
|
.from(schema.boards)
|
||||||
|
.where(eq(schema.boards.id, boardId));
|
||||||
|
return !!board?.locked;
|
||||||
|
};
|
||||||
|
// guard combinado: sesión válida y board no bloqueado
|
||||||
|
const writable = async (): Promise<SessionData | null> => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return null;
|
||||||
|
if (await isLocked(s.boardId)) return null;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- notas ----------
|
||||||
|
socket.on("note:add", async (p: { columnId: string; text: string; color?: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
await db.insert(schema.notes).values({
|
||||||
|
id: nanoid(),
|
||||||
|
boardId: s.boardId,
|
||||||
|
columnId: p.columnId,
|
||||||
|
authorId: s.participantId,
|
||||||
|
authorName: s.name,
|
||||||
|
text: p.text ?? "",
|
||||||
|
color: typeof p.color === "string" ? p.color.slice(0, 16) : null,
|
||||||
|
position: Date.now(), // ordenación por creación
|
||||||
|
});
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("note:update", async (p: { noteId: string; text: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
.set({ text: p.text })
|
||||||
|
.where(and(eq(schema.notes.id, p.noteId), eq(schema.notes.authorId, s.participantId)));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("note:delete", async (p: { noteId: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
const [note] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.noteId));
|
||||||
|
if (!note || note.authorId !== s.participantId) return;
|
||||||
|
await db.delete(schema.votes).where(eq(schema.votes.targetId, p.noteId));
|
||||||
|
await db.delete(schema.notes).where(eq(schema.notes.id, p.noteId));
|
||||||
|
if (note.groupId) await deleteGroupIfEmpty(note.groupId);
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// el autor publica/despublica su propia nota
|
||||||
|
socket.on("note:reveal", async (p: { noteId: string; revealed: boolean }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
.set({ revealed: p.revealed })
|
||||||
|
.where(and(eq(schema.notes.id, p.noteId), eq(schema.notes.authorId, s.participantId)));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cambiar mi color: recolorea TODAS mis notas (y vale para las futuras en el cliente)
|
||||||
|
socket.on("note:recolorMine", async (p: { color: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
const color = typeof p.color === "string" ? p.color.slice(0, 16) : null;
|
||||||
|
if (!color) return;
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
.set({ color })
|
||||||
|
.where(and(eq(schema.notes.boardId, s.boardId), eq(schema.notes.authorId, s.participantId)));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Publish all": publica todas mis notas (opcionalmente de una columna)
|
||||||
|
socket.on("note:publishMine", async (p: { columnId?: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
const filters = [eq(schema.notes.boardId, s.boardId), eq(schema.notes.authorId, s.participantId)];
|
||||||
|
if (p.columnId) filters.push(eq(schema.notes.columnId, p.columnId));
|
||||||
|
await db.update(schema.notes).set({ revealed: true }).where(and(...filters));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// colocación unificada: mover de columna, reordenar y entrar/salir de grupo
|
||||||
|
socket.on(
|
||||||
|
"note:move",
|
||||||
|
async (p: { noteId: string; columnId: string; groupId?: string | null; position: number }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
const [note] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.noteId));
|
||||||
|
if (!note) return;
|
||||||
|
const newGroup = p.groupId ?? null;
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
// entrar en un grupo implica publicar la nota (agrupar es acción pública)
|
||||||
|
.set({ columnId: p.columnId, groupId: newGroup, position: p.position, ...(newGroup ? { revealed: true } : {}) })
|
||||||
|
.where(eq(schema.notes.id, p.noteId));
|
||||||
|
if (newGroup !== note.groupId) {
|
||||||
|
// cambia su estado de agrupación => sus votos sueltos dejan de aplicar
|
||||||
|
await db.delete(schema.votes).where(eq(schema.votes.targetId, p.noteId));
|
||||||
|
if (note.groupId) await deleteGroupIfEmpty(note.groupId);
|
||||||
|
}
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- grupos ----------
|
||||||
|
// soltar una nota sobre otra: crea grupo (o se une al de la nota destino)
|
||||||
|
socket.on("group:create", async (p: { noteId: string; targetNoteId: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
if (p.noteId === p.targetNoteId) return;
|
||||||
|
const [target] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.targetNoteId));
|
||||||
|
const [dragged] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.noteId));
|
||||||
|
if (!target || !dragged) return;
|
||||||
|
|
||||||
|
let groupId = target.groupId;
|
||||||
|
if (!groupId) {
|
||||||
|
groupId = nanoid();
|
||||||
|
await db.insert(schema.groups).values({
|
||||||
|
id: groupId,
|
||||||
|
boardId: s.boardId,
|
||||||
|
columnId: target.columnId,
|
||||||
|
title: target.text.slice(0, 60), // por defecto, el texto de la primera nota (la ancla)
|
||||||
|
position: target.position,
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
.set({ groupId, position: 0, revealed: true })
|
||||||
|
.where(eq(schema.notes.id, target.id));
|
||||||
|
}
|
||||||
|
const oldGroup = dragged.groupId;
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
.set({ groupId, columnId: target.columnId, position: Date.now(), revealed: true })
|
||||||
|
.where(eq(schema.notes.id, dragged.id));
|
||||||
|
|
||||||
|
// votar el grupo es la unidad => reiniciamos votos sueltos de las notas implicadas
|
||||||
|
await db.delete(schema.votes).where(eq(schema.votes.targetId, target.id));
|
||||||
|
await db.delete(schema.votes).where(eq(schema.votes.targetId, dragged.id));
|
||||||
|
if (oldGroup && oldGroup !== groupId) await deleteGroupIfEmpty(oldGroup);
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("group:rename", async (p: { groupId: string; title: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
await db.update(schema.groups).set({ title: p.title }).where(eq(schema.groups.id, p.groupId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("group:move", async (p: { groupId: string; columnId: string; position: number }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
await db
|
||||||
|
.update(schema.groups)
|
||||||
|
.set({ columnId: p.columnId, position: p.position })
|
||||||
|
.where(eq(schema.groups.id, p.groupId));
|
||||||
|
// las notas del grupo siguen al grupo de columna
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
.set({ columnId: p.columnId })
|
||||||
|
.where(eq(schema.notes.groupId, p.groupId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("group:delete", async (p: { groupId: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
await db.update(schema.notes).set({ groupId: null }).where(eq(schema.notes.groupId, p.groupId));
|
||||||
|
await db.delete(schema.votes).where(eq(schema.votes.targetId, p.groupId));
|
||||||
|
await db.delete(schema.groups).where(eq(schema.groups.id, p.groupId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- votos ----------
|
||||||
|
// presupuesto usado por el participante = suma de contadores
|
||||||
|
const usedVotes = (tx: Pick<typeof db, "select">, boardId: string, participantId: string) =>
|
||||||
|
tx
|
||||||
|
.select({ used: sql<number>`coalesce(sum(${schema.votes.count}), 0)::int` })
|
||||||
|
.from(schema.votes)
|
||||||
|
.where(and(eq(schema.votes.boardId, boardId), eq(schema.votes.participantId, participantId)))
|
||||||
|
.then((r) => r[0]?.used ?? 0);
|
||||||
|
|
||||||
|
// añade un voto (apila si allowMultiVote); respeta el cupo total por persona
|
||||||
|
socket.on("vote:add", async (p: { targetType?: string; targetId?: string; noteId?: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
const targetType = p.targetType ?? "note";
|
||||||
|
const targetId = p.targetId ?? p.noteId;
|
||||||
|
if (!targetId) return;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [board] = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema.boards)
|
||||||
|
.where(eq(schema.boards.id, s.boardId))
|
||||||
|
.for("update");
|
||||||
|
if (!board) return;
|
||||||
|
if ((await usedVotes(tx, s.boardId, s.participantId)) >= board.votesPerUser) return; // cupo agotado
|
||||||
|
const [row] = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema.votes)
|
||||||
|
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
|
||||||
|
if (row) {
|
||||||
|
if (!board.allowMultiVote) return; // ya votado y no se permite apilar
|
||||||
|
await tx
|
||||||
|
.update(schema.votes)
|
||||||
|
.set({ count: row.count + 1 })
|
||||||
|
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
|
||||||
|
} else {
|
||||||
|
await tx.insert(schema.votes).values({
|
||||||
|
boardId: s.boardId,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
participantId: s.participantId,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// quita un voto (decrementa; borra la fila al llegar a 0)
|
||||||
|
socket.on("vote:remove", async (p: { targetId?: string; noteId?: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
const targetId = p.targetId ?? p.noteId;
|
||||||
|
if (!targetId) return;
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.votes)
|
||||||
|
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
|
||||||
|
if (!row) return;
|
||||||
|
if (row.count > 1) {
|
||||||
|
await db
|
||||||
|
.update(schema.votes)
|
||||||
|
.set({ count: row.count - 1 })
|
||||||
|
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.delete(schema.votes)
|
||||||
|
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
|
||||||
|
}
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// legacy: alterna por completo el voto de un ítem (todo/nada)
|
||||||
|
socket.on("vote:toggle", async (p: { targetType?: string; targetId?: string; noteId?: string }) => {
|
||||||
|
const s = await writable();
|
||||||
|
if (!s) return;
|
||||||
|
const targetType = p.targetType ?? "note";
|
||||||
|
const targetId = p.targetId ?? p.noteId;
|
||||||
|
if (!targetId) return;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [board] = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema.boards)
|
||||||
|
.where(eq(schema.boards.id, s.boardId))
|
||||||
|
.for("update");
|
||||||
|
if (!board) return;
|
||||||
|
const [row] = await tx
|
||||||
|
.select()
|
||||||
|
.from(schema.votes)
|
||||||
|
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
|
||||||
|
if (row) {
|
||||||
|
await tx
|
||||||
|
.delete(schema.votes)
|
||||||
|
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((await usedVotes(tx, s.boardId, s.participantId)) >= board.votesPerUser) return;
|
||||||
|
await tx
|
||||||
|
.insert(schema.votes)
|
||||||
|
.values({ boardId: s.boardId, targetType, targetId, participantId: s.participantId, count: 1 });
|
||||||
|
});
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- ajustes del board (moderador) ----------
|
||||||
|
socket.on(
|
||||||
|
"board:update",
|
||||||
|
async (p: {
|
||||||
|
title?: string;
|
||||||
|
votesPerUser?: number;
|
||||||
|
allowMultiVote?: boolean;
|
||||||
|
moderationMode?: string;
|
||||||
|
showCardCreators?: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
moderatorToken?: string;
|
||||||
|
}) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
const set: Record<string, unknown> = {};
|
||||||
|
if (typeof p.title === "string" && p.title.trim()) set.title = p.title.trim();
|
||||||
|
if (typeof p.votesPerUser === "number") set.votesPerUser = Math.max(0, Math.min(50, Math.round(p.votesPerUser)));
|
||||||
|
if (typeof p.allowMultiVote === "boolean") set.allowMultiVote = p.allowMultiVote;
|
||||||
|
if (p.moderationMode === "everyone" || p.moderationMode === "single") set.moderationMode = p.moderationMode;
|
||||||
|
if (typeof p.showCardCreators === "boolean") set.showCardCreators = p.showCardCreators;
|
||||||
|
if (typeof p.locked === "boolean") set.locked = p.locked;
|
||||||
|
if (Object.keys(set).length) await db.update(schema.boards).set(set).where(eq(schema.boards.id, s.boardId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// reclamar la retro: pasa a moderación 'single' con un token nuevo solo para ti
|
||||||
|
socket.on("board:claim", async (p: { moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
const token = nanoid(24);
|
||||||
|
await db
|
||||||
|
.update(schema.boards)
|
||||||
|
.set({ moderationMode: "single", moderatorToken: token })
|
||||||
|
.where(eq(schema.boards.id, s.boardId));
|
||||||
|
const sess = sessions.get(socket.id);
|
||||||
|
if (sess) sess.moderatorToken = token;
|
||||||
|
socket.emit("board:claimed", { moderatorToken: token });
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// quitar todos los votos del board (moderador)
|
||||||
|
socket.on("votes:clear", async (p: { moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
await db.delete(schema.votes).where(eq(schema.votes.boardId, s.boardId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// borrar la retro (moderador)
|
||||||
|
socket.on("board:remove", async (p: { moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
await db.delete(schema.boards).where(eq(schema.boards.id, s.boardId));
|
||||||
|
io.to(s.boardId).emit("board:destroyed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- columnas (moderador) ----------
|
||||||
|
socket.on("column:add", async (p: { title?: string; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
const [{ max }] = await db
|
||||||
|
.select({ max: sql<number>`coalesce(max(${schema.columns.position}), -1)::int` })
|
||||||
|
.from(schema.columns)
|
||||||
|
.where(eq(schema.columns.boardId, s.boardId));
|
||||||
|
await db.insert(schema.columns).values({
|
||||||
|
id: nanoid(),
|
||||||
|
boardId: s.boardId,
|
||||||
|
title: (p.title ?? "").trim() || "Nueva columna",
|
||||||
|
position: max + 1,
|
||||||
|
});
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("column:rename", async (p: { columnId: string; title: string; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
const title = (p.title ?? "").trim();
|
||||||
|
if (!title) return;
|
||||||
|
await db
|
||||||
|
.update(schema.columns)
|
||||||
|
.set({ title })
|
||||||
|
.where(and(eq(schema.columns.id, p.columnId), eq(schema.columns.boardId, s.boardId)));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("column:delete", async (p: { columnId: string; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
// no dejar el board sin columnas
|
||||||
|
const cols = await db.select({ id: schema.columns.id }).from(schema.columns).where(eq(schema.columns.boardId, s.boardId));
|
||||||
|
if (cols.length <= 1) return;
|
||||||
|
await db
|
||||||
|
.delete(schema.columns)
|
||||||
|
.where(and(eq(schema.columns.id, p.columnId), eq(schema.columns.boardId, s.boardId)));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// reordenar: el cliente envía el orden deseado de ids
|
||||||
|
socket.on("column:reorder", async (p: { orderedIds: string[]; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
if (!Array.isArray(p.orderedIds)) return;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (let i = 0; i < p.orderedIds.length; i++) {
|
||||||
|
await tx
|
||||||
|
.update(schema.columns)
|
||||||
|
.set({ position: i })
|
||||||
|
.where(and(eq(schema.columns.id, p.orderedIds[i]), eq(schema.columns.boardId, s.boardId)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cambiar mi propio nombre (actualiza la autoría de mis notas)
|
||||||
|
socket.on("participant:rename", async (p: { name: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
const name = (p.name ?? "").trim();
|
||||||
|
if (!name) return;
|
||||||
|
s.name = name;
|
||||||
|
await db
|
||||||
|
.update(schema.notes)
|
||||||
|
.set({ authorName: name })
|
||||||
|
.where(and(eq(schema.notes.boardId, s.boardId), eq(schema.notes.authorId, s.participantId)));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("board:reveal", async (p: { revealed: boolean; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
await db.update(schema.boards).set({ revealed: p.revealed }).where(eq(schema.boards.id, s.boardId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("board:revealVotes", async (p: { revealed: boolean; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
await db.update(schema.boards).set({ votesRevealed: p.revealed }).where(eq(schema.boards.id, s.boardId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// fase de la retro (Lluvia/Votación/Discusión), compartida (moderador)
|
||||||
|
socket.on("board:phase", async (p: { phase: string; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
if (!["writing", "voting", "done", "revealed"].includes(p.phase)) return;
|
||||||
|
await db.update(schema.boards).set({ phase: p.phase }).where(eq(schema.boards.id, s.boardId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timer:start", async (p: { seconds: number; moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
const endsAt = new Date(Date.now() + Math.max(1, p.seconds) * 1000);
|
||||||
|
await db
|
||||||
|
.update(schema.boards)
|
||||||
|
.set({ timerEndsAt: endsAt, timerRunning: true })
|
||||||
|
.where(eq(schema.boards.id, s.boardId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timer:stop", async (p: { moderatorToken?: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
|
||||||
|
await db.update(schema.boards).set({ timerRunning: false }).where(eq(schema.boards.id, s.boardId));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- action items ----------
|
||||||
|
socket.on("actionitem:add", async (p: { text: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
await db.insert(schema.actionItems).values({ id: nanoid(), boardId: s.boardId, text: p.text, owner: null });
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("actionitem:update", async (p: { id: string; text?: string; owner?: string | null }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
const set: Record<string, unknown> = {};
|
||||||
|
if (p.text !== undefined) set.text = p.text;
|
||||||
|
if (p.owner !== undefined) set.owner = p.owner;
|
||||||
|
await db.update(schema.actionItems).set(set).where(eq(schema.actionItems.id, p.id));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("actionitem:delete", async (p: { id: string }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
await db.delete(schema.actionItems).where(eq(schema.actionItems.id, p.id));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("actionitem:toggle", async (p: { id: string; done: boolean }) => {
|
||||||
|
const s = guard();
|
||||||
|
if (!s) return;
|
||||||
|
await db.update(schema.actionItems).set({ done: p.done }).where(eq(schema.actionItems.id, p.id));
|
||||||
|
await broadcast(io, s.boardId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Borra un grupo si se ha quedado sin notas (y sus votos).
|
||||||
|
async function deleteGroupIfEmpty(groupId: string) {
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(schema.notes)
|
||||||
|
.where(eq(schema.notes.groupId, groupId));
|
||||||
|
if (count === 0) {
|
||||||
|
await db.delete(schema.votes).where(eq(schema.votes.targetId, groupId));
|
||||||
|
await db.delete(schema.groups).where(eq(schema.groups.id, groupId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difunde a cada socket de la room su propia vista: oculta notas/votos si procede,
|
||||||
|
// e inyecta la presencia y si ese viewer es moderador.
|
||||||
|
async function broadcast(io: Server, boardId: string) {
|
||||||
|
const state = await loadBoardState(boardId);
|
||||||
|
if (!state) return;
|
||||||
|
// token del moderador (secreto, no va en el estado público): solo para comparar
|
||||||
|
const [board] = await db
|
||||||
|
.select({ token: schema.boards.moderatorToken })
|
||||||
|
.from(schema.boards)
|
||||||
|
.where(eq(schema.boards.id, boardId));
|
||||||
|
const modToken = board?.token;
|
||||||
|
|
||||||
|
const socketsInRoom = await io.in(boardId).fetchSockets();
|
||||||
|
|
||||||
|
// presencia: participantes conectados, deduplicados por participantId
|
||||||
|
const byId = new Map<string, string>();
|
||||||
|
for (const s of socketsInRoom) {
|
||||||
|
const sess = sessions.get(s.id);
|
||||||
|
if (sess) byId.set(sess.participantId, sess.name);
|
||||||
|
}
|
||||||
|
const participants = [...byId].map(([id, name]) => ({ id, name }));
|
||||||
|
|
||||||
|
for (const s of socketsInRoom) {
|
||||||
|
const sess = sessions.get(s.id);
|
||||||
|
const pid = sess?.participantId;
|
||||||
|
const view = pid ? viewFor(state, pid) : state;
|
||||||
|
const youAreModerator =
|
||||||
|
state.moderationMode === "everyone" || (!!sess?.moderatorToken && sess.moderatorToken === modToken);
|
||||||
|
s.emit("board:state", { ...view, participants, youAreModerator });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Plantillas predefinidas: cada una es una lista ordenada de columnas.
|
||||||
|
export type Template = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
columns: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEMPLATES: Template[] = [
|
||||||
|
{
|
||||||
|
id: "mad-sad-glad",
|
||||||
|
name: "Mad / Sad / Glad",
|
||||||
|
columns: ["😠 Mad", "😢 Sad", "😄 Glad"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "start-stop-continue",
|
||||||
|
name: "Start / Stop / Continue",
|
||||||
|
columns: ["🟢 Start", "🔴 Stop", "🔵 Continue"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4ls",
|
||||||
|
name: "4 Ls",
|
||||||
|
columns: ["👍 Liked", "📚 Learned", "🕳️ Lacked", "🎯 Longed for"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "went-well",
|
||||||
|
name: "Funcionó / No funcionó / Acciones",
|
||||||
|
columns: ["✅ Funcionó", "⚠️ No funcionó", "💡 Acciones"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sailboat",
|
||||||
|
name: "Velero (Sailboat)",
|
||||||
|
columns: ["⛵ Viento", "⚓ Ancla", "🪨 Rocas", "🏝️ Meta"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "daki",
|
||||||
|
name: "DAKI",
|
||||||
|
columns: ["🗑️ Drop", "➕ Add", "📌 Keep", "📈 Improve"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blank",
|
||||||
|
name: "En blanco (columnas libres)",
|
||||||
|
columns: ["Columna 1", "Columna 2", "Columna 3"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTemplate(id: string): Template | undefined {
|
||||||
|
return TEMPLATES.find((t) => t.id === id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
import { runCleanup } from "../src/app.js";
|
||||||
|
import { db, schema } from "../src/db/index.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Autodestrucción", () => {
|
||||||
|
it("borra boards caducados y avisa a la room", async () => {
|
||||||
|
const board = await createBoard(server.url, { ttlHours: 1 });
|
||||||
|
// forzamos expires_at al pasado
|
||||||
|
await db
|
||||||
|
.update(schema.boards)
|
||||||
|
.set({ expiresAt: new Date(Date.now() - 1000) })
|
||||||
|
.where(eq(schema.boards.id, board.boardId));
|
||||||
|
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const destroyed = new Promise<void>((resolve) =>
|
||||||
|
alice.socket.once("board:destroyed", () => resolve()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleted = await runCleanup(server.io);
|
||||||
|
expect(deleted).toContain(board.boardId);
|
||||||
|
await destroyed; // la room recibió el aviso
|
||||||
|
|
||||||
|
// ya no existe en DB
|
||||||
|
const rows = await db.select().from(schema.boards).where(eq(schema.boards.id, board.boardId));
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
|
||||||
|
alice.socket.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no borra boards sin caducidad ni los aún vigentes", async () => {
|
||||||
|
const persistent = await createBoard(server.url, { ttlHours: null });
|
||||||
|
const future = await createBoard(server.url, { ttlHours: 24 });
|
||||||
|
|
||||||
|
const deleted = await runCleanup(server.io);
|
||||||
|
expect(deleted).not.toContain(persistent.boardId);
|
||||||
|
expect(deleted).not.toContain(future.boardId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Columnas y color", () => {
|
||||||
|
it("recolorear cambia el color de TODAS mis notas", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "a", color: "#ffe08a" });
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "b", color: "#ffe08a" });
|
||||||
|
await alice.waitFor((s) => s.notes.length === 2);
|
||||||
|
|
||||||
|
alice.emit("note:recolorMine", { color: "#b9ddf5" });
|
||||||
|
const recolored = await alice.waitFor((s) => s.notes.every((n) => n.color === "#b9ddf5"));
|
||||||
|
expect(recolored.notes.map((n) => n.color)).toEqual(["#b9ddf5", "#b9ddf5"]);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("añadir, renombrar, reordenar y eliminar columnas (moderador)", async () => {
|
||||||
|
const board = await createBoard(server.url); // mad-sad-glad => 3 columnas
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const tok = board.moderatorToken;
|
||||||
|
|
||||||
|
// añadir
|
||||||
|
alice.emit("column:add", { title: "Extra", moderatorToken: tok });
|
||||||
|
let st = await alice.waitFor((s) => s.columns.length === 4);
|
||||||
|
const extra = st.columns.find((c) => c.title === "Extra")!;
|
||||||
|
expect(extra).toBeTruthy();
|
||||||
|
|
||||||
|
// renombrar
|
||||||
|
alice.emit("column:rename", { columnId: extra.id, title: "Renombrada", moderatorToken: tok });
|
||||||
|
st = await alice.waitFor((s) => s.columns.some((c) => c.id === extra.id && c.title === "Renombrada"));
|
||||||
|
expect(st.columns.find((c) => c.id === extra.id)!.title).toBe("Renombrada");
|
||||||
|
|
||||||
|
// reordenar: poner la última primera
|
||||||
|
const ids = st.columns.map((c) => c.id);
|
||||||
|
const reordered = [ids[ids.length - 1], ...ids.slice(0, -1)];
|
||||||
|
alice.emit("column:reorder", { orderedIds: reordered, moderatorToken: tok });
|
||||||
|
st = await alice.waitFor((s) => s.columns[0].id === reordered[0]);
|
||||||
|
expect(st.columns.map((c) => c.id)).toEqual(reordered);
|
||||||
|
|
||||||
|
// eliminar
|
||||||
|
alice.emit("column:delete", { columnId: extra.id, moderatorToken: tok });
|
||||||
|
st = await alice.waitFor((s) => s.columns.length === 3);
|
||||||
|
expect(st.columns.some((c) => c.id === extra.id)).toBe(false);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("la fase se comparte (board:phase)", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
expect(alice.last!.phase).toBe("writing");
|
||||||
|
|
||||||
|
alice.emit("board:phase", { phase: "voting", moderatorToken: board.moderatorToken });
|
||||||
|
const bobState = await bob.waitFor((s) => s.phase === "voting");
|
||||||
|
expect(bobState.phase).toBe("voting");
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
// DDL self-contained: crea la base de datos de test desde cero (idempotente).
|
||||||
|
const DDL = `
|
||||||
|
DROP TABLE IF EXISTS action_items, votes, notes, groups, columns, boards CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE boards (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
title text NOT NULL,
|
||||||
|
phase text NOT NULL DEFAULT 'writing',
|
||||||
|
revealed boolean NOT NULL DEFAULT false,
|
||||||
|
votes_revealed boolean NOT NULL DEFAULT false,
|
||||||
|
votes_per_user integer NOT NULL DEFAULT 3,
|
||||||
|
allow_multi_vote boolean NOT NULL DEFAULT true,
|
||||||
|
moderation_mode text NOT NULL DEFAULT 'everyone',
|
||||||
|
show_card_creators boolean NOT NULL DEFAULT true,
|
||||||
|
locked boolean NOT NULL DEFAULT false,
|
||||||
|
timer_ends_at timestamptz,
|
||||||
|
timer_running boolean NOT NULL DEFAULT false,
|
||||||
|
moderator_token text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
expires_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE columns (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
position integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE groups (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL DEFAULT '',
|
||||||
|
position double precision NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||||
|
group_id text REFERENCES groups(id) ON DELETE SET NULL,
|
||||||
|
author_id text NOT NULL,
|
||||||
|
author_name text NOT NULL,
|
||||||
|
text text NOT NULL,
|
||||||
|
color text,
|
||||||
|
revealed boolean NOT NULL DEFAULT false,
|
||||||
|
position double precision NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE votes (
|
||||||
|
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
target_type text NOT NULL,
|
||||||
|
target_id text NOT NULL,
|
||||||
|
participant_id text NOT NULL,
|
||||||
|
count integer NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX votes_target_participant_uniq ON votes (target_id, participant_id);
|
||||||
|
|
||||||
|
CREATE TABLE action_items (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||||
|
text text NOT NULL,
|
||||||
|
owner text,
|
||||||
|
done boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function setup() {
|
||||||
|
// globalSetup no recibe `test.env`; replicamos el default de vitest.config.ts.
|
||||||
|
const url =
|
||||||
|
process.env.TEST_DATABASE_URL ??
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://retro:retro@localhost:5433/retro_test";
|
||||||
|
// Asegura que exista la base de datos de test (crea retro_test si falta).
|
||||||
|
const dbName = new URL(url).pathname.slice(1);
|
||||||
|
const adminUrl = url.replace(`/${dbName}`, "/postgres");
|
||||||
|
const admin = postgres(adminUrl, { max: 1 });
|
||||||
|
try {
|
||||||
|
const exists = await admin`SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
|
||||||
|
if (exists.length === 0) await admin.unsafe(`CREATE DATABASE ${dbName}`);
|
||||||
|
} finally {
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = postgres(url, { max: 1 });
|
||||||
|
try {
|
||||||
|
await sql.unsafe(DDL);
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function twoNotes(boardId: string, url: string) {
|
||||||
|
const alice = await join(url, boardId, "alice", "Alice");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "A" });
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "B" });
|
||||||
|
const st = await alice.waitFor((s) => s.notes.length === 2);
|
||||||
|
const [a, b] = st.notes;
|
||||||
|
return { alice, colId, a, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Grupos", () => {
|
||||||
|
it("agrupar dos notas crea un grupo votable", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const { alice, a, b } = await twoNotes(board.boardId, server.url);
|
||||||
|
|
||||||
|
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||||
|
const grouped = await alice.waitFor((s) => s.groups.length === 1);
|
||||||
|
const groupId = grouped.groups[0].id;
|
||||||
|
expect(grouped.notes.every((n) => n.groupId === groupId)).toBe(true);
|
||||||
|
|
||||||
|
// votar el grupo como unidad
|
||||||
|
alice.emit("vote:toggle", { targetType: "group", targetId: groupId });
|
||||||
|
const voted = await alice.waitFor((s) => s.votes.some((v) => v.targetId === groupId));
|
||||||
|
expect(voted.votes.filter((v) => v.targetId === groupId)).toHaveLength(1);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("al agrupar se reinician los votos sueltos de las notas", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const { alice, a, b } = await twoNotes(board.boardId, server.url);
|
||||||
|
|
||||||
|
alice.emit("vote:toggle", { targetType: "note", targetId: a.id });
|
||||||
|
await alice.waitFor((s) => s.votes.some((v) => v.targetId === a.id));
|
||||||
|
|
||||||
|
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||||
|
const after = await alice.waitFor((s) => s.groups.length === 1);
|
||||||
|
expect(after.votes.filter((v) => v.targetId === a.id)).toHaveLength(0);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sacar la última nota de un grupo lo elimina", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const { alice, colId, a, b } = await twoNotes(board.boardId, server.url);
|
||||||
|
|
||||||
|
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||||
|
const grouped = await alice.waitFor((s) => s.groups.length === 1);
|
||||||
|
expect(grouped.groups).toHaveLength(1);
|
||||||
|
|
||||||
|
// saca una nota: el grupo sigue (1 miembro)
|
||||||
|
alice.emit("note:move", { noteId: a.id, columnId: colId, groupId: null, position: 5 });
|
||||||
|
await alice.waitFor((s) => s.notes.filter((n) => n.groupId).length === 1);
|
||||||
|
|
||||||
|
// saca la última: el grupo se borra
|
||||||
|
alice.emit("note:move", { noteId: b.id, columnId: colId, groupId: null, position: 6 });
|
||||||
|
const empty = await alice.waitFor((s) => s.groups.length === 0);
|
||||||
|
expect(empty.groups).toHaveLength(0);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { buildServer, type BuiltServer } from "../src/app.js";
|
||||||
|
import { io as ioc, type Socket } from "socket.io-client";
|
||||||
|
import type { BoardState } from "../src/room.js";
|
||||||
|
|
||||||
|
export type TestServer = BuiltServer & { port: number; url: string };
|
||||||
|
|
||||||
|
export async function startTestServer(): Promise<TestServer> {
|
||||||
|
const server = buildServer({ cleanup: false });
|
||||||
|
const port = await server.listen(0); // puerto efímero
|
||||||
|
return Object.assign(server, { port, url: `http://localhost:${port}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBoard(
|
||||||
|
url: string,
|
||||||
|
body: Record<string, unknown> = {},
|
||||||
|
): Promise<{ boardId: string; moderatorToken: string; expiresAt: string | null }> {
|
||||||
|
const res = await fetch(`${url}/api/boards`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ templateId: "mad-sad-glad", votesPerUser: 3, ...body }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cliente de test: mantiene el último estado recibido para evitar carreras
|
||||||
|
// (el estado buscado puede haber llegado antes de adjuntar el listener).
|
||||||
|
export class Client {
|
||||||
|
last: BoardState | null = null;
|
||||||
|
constructor(public socket: Socket) {
|
||||||
|
socket.on("board:state", (s: BoardState) => (this.last = s));
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, payload: Record<string, unknown>) {
|
||||||
|
this.socket.emit(event, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resuelve cuando el último estado (presente o futuro) cumple el predicado.
|
||||||
|
waitFor(predicate: (s: BoardState) => boolean, timeoutMs = 10000): Promise<BoardState> {
|
||||||
|
if (this.last && predicate(this.last)) return Promise.resolve(this.last);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.socket.off("board:state", handler);
|
||||||
|
reject(new Error("waitFor timeout"));
|
||||||
|
}, timeoutMs);
|
||||||
|
const handler = (s: BoardState) => {
|
||||||
|
if (predicate(s)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.socket.off("board:state", handler);
|
||||||
|
resolve(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.socket.on("board:state", handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conecta, hace join y devuelve un Client ya con su primer estado.
|
||||||
|
export function join(
|
||||||
|
url: string,
|
||||||
|
boardId: string,
|
||||||
|
participantId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<Client> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = ioc(url, { transports: ["websocket"], forceNew: true });
|
||||||
|
const client = new Client(socket);
|
||||||
|
const timer = setTimeout(() => reject(new Error("join timeout")), 10000);
|
||||||
|
socket.on("connect", () => socket.emit("board:join", { boardId, participantId, name }));
|
||||||
|
socket.once("error", (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`join error: ${JSON.stringify(e)}`));
|
||||||
|
});
|
||||||
|
const ready = (s: BoardState) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.off("board:state", ready);
|
||||||
|
client.last = s;
|
||||||
|
resolve(client);
|
||||||
|
};
|
||||||
|
socket.on("board:state", ready);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
version: 1,
|
||||||
|
board: { title: "Importado", votesPerUser: 4 },
|
||||||
|
columns: [
|
||||||
|
{ id: "c1", title: "Columna A", position: 0 },
|
||||||
|
{ id: "c2", title: "Columna B", position: 1 },
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
id: "n1",
|
||||||
|
columnId: "c1",
|
||||||
|
groupId: null,
|
||||||
|
authorId: "x",
|
||||||
|
authorName: "X",
|
||||||
|
text: "nota importada",
|
||||||
|
revealed: true,
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
votes: [{ targetType: "note", targetId: "n1", participantId: "x" }],
|
||||||
|
actionItems: [{ text: "tarea importada", owner: "X" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function importAndExport(mode: "full" | "structure") {
|
||||||
|
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ snapshot, mode }),
|
||||||
|
});
|
||||||
|
const { boardId } = await res.json();
|
||||||
|
const md = await (await fetch(`${server.url}/api/boards/${boardId}/export`)).text();
|
||||||
|
return { boardId, md };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Import", () => {
|
||||||
|
it("modo 'full' importa columnas, notas y votos", async () => {
|
||||||
|
const { md } = await importAndExport("full");
|
||||||
|
expect(md).toContain("# Importado");
|
||||||
|
expect(md).toContain("## Columna A");
|
||||||
|
expect(md).toContain("nota importada — _X_ (1 👍)");
|
||||||
|
expect(md).toContain("tarea importada");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("modo 'structure' importa solo columnas y config, sin notas", async () => {
|
||||||
|
const { md } = await importAndExport("structure");
|
||||||
|
expect(md).toContain("## Columna A");
|
||||||
|
expect(md).toContain("## Columna B");
|
||||||
|
expect(md).not.toContain("nota importada");
|
||||||
|
expect(md).not.toContain("tarea importada");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechaza un snapshot sin columnas", async () => {
|
||||||
|
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ snapshot: { version: 1, columns: [] }, mode: "full" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Export con opciones", () => {
|
||||||
|
async function importFull(): Promise<string> {
|
||||||
|
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ snapshot, mode: "full" }),
|
||||||
|
});
|
||||||
|
return (await res.json()).boardId;
|
||||||
|
}
|
||||||
|
const md = (id: string, qs: string) =>
|
||||||
|
fetch(`${server.url}/api/boards/${id}/export${qs}`).then((r) => r.text());
|
||||||
|
|
||||||
|
it("autores y votos se pueden desactivar por query", async () => {
|
||||||
|
const id = await importFull();
|
||||||
|
expect(await md(id, "")).toContain("nota importada — _X_ (1 👍)"); // defaults
|
||||||
|
const noAuthors = await md(id, "?authors=0");
|
||||||
|
expect(noAuthors).toContain("nota importada (1 👍)");
|
||||||
|
expect(noAuthors).not.toContain("_X_");
|
||||||
|
const noVotes = await md(id, "?votes=0");
|
||||||
|
expect(noVotes).not.toContain("👍");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("la fecha de creación se añade al título con created=1", async () => {
|
||||||
|
const id = await importFull();
|
||||||
|
const withDate = await md(id, "?created=1");
|
||||||
|
expect(withDate).toMatch(/^# \d+\/\d+\/\d+ - Importado/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const myCount = (state: { votes: { targetId: string; participantId: string; count: number }[] }, targetId: string) =>
|
||||||
|
state.votes
|
||||||
|
.filter((v) => v.targetId === targetId && v.participantId === "alice")
|
||||||
|
.reduce((s, v) => s + v.count, 0);
|
||||||
|
|
||||||
|
describe("Multivoto", () => {
|
||||||
|
it("apila votos en el mismo ítem hasta el cupo y deja quitar", async () => {
|
||||||
|
const board = await createBoard(server.url, { votesPerUser: 3 }); // allowMultiVote por defecto: true
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "favorita" });
|
||||||
|
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||||
|
|
||||||
|
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||||
|
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||||
|
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||||
|
const stacked = await alice.waitFor((s) => myCount(s, noteId) === 3);
|
||||||
|
expect(myCount(stacked, noteId)).toBe(3);
|
||||||
|
|
||||||
|
// 4º voto: cupo agotado, no sube
|
||||||
|
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(myCount(alice.last!, noteId)).toBe(3);
|
||||||
|
|
||||||
|
// quitar uno
|
||||||
|
alice.emit("vote:remove", { targetId: noteId });
|
||||||
|
const afterRemove = await alice.waitFor((s) => myCount(s, noteId) === 2);
|
||||||
|
expect(myCount(afterRemove, noteId)).toBe(2);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("con allowMultiVote=false no se puede apilar en el mismo ítem", async () => {
|
||||||
|
const board = await createBoard(server.url, { votesPerUser: 5 });
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
alice.emit("board:update", { allowMultiVote: false, moderatorToken: board.moderatorToken });
|
||||||
|
await alice.waitFor((s) => s.allowMultiVote === false);
|
||||||
|
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "uno" });
|
||||||
|
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||||
|
|
||||||
|
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||||
|
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||||
|
const after = await alice.waitFor((s) => myCount(s, noteId) >= 1);
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(myCount(after, noteId)).toBe(1);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Moderación y opciones", () => {
|
||||||
|
it("por defecto cualquiera modera; al reclamar solo el reclamante", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
|
||||||
|
// modo abierto por defecto
|
||||||
|
expect(alice.last!.moderationMode).toBe("everyone");
|
||||||
|
|
||||||
|
// Alice reclama (sin token, permitido en modo abierto) y recibe un token nuevo
|
||||||
|
const claimed = new Promise<string>((resolve) =>
|
||||||
|
alice.socket.once("board:claimed", (p: { moderatorToken: string }) => resolve(p.moderatorToken)),
|
||||||
|
);
|
||||||
|
alice.emit("board:claim", {});
|
||||||
|
const token = await claimed;
|
||||||
|
await alice.waitFor((s) => s.moderationMode === "single");
|
||||||
|
|
||||||
|
// Bob (sin token) ya no puede revelar; Alice con su token sí
|
||||||
|
bob.emit("board:reveal", { revealed: true });
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
expect(bob.last!.revealed).toBe(false);
|
||||||
|
|
||||||
|
alice.emit("board:reveal", { revealed: true, moderatorToken: token });
|
||||||
|
const revealed = await alice.waitFor((s) => s.revealed === true);
|
||||||
|
expect(revealed.revealed).toBe(true);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bloquear deja el board en solo lectura (no se añaden notas)", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
alice.emit("board:update", { locked: true }); // modo abierto: cualquiera modera
|
||||||
|
await alice.waitFor((s) => s.locked === true);
|
||||||
|
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "no debería entrar" });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(alice.last!.notes).toHaveLength(0);
|
||||||
|
|
||||||
|
// desbloquear y ahora sí
|
||||||
|
alice.emit("board:update", { locked: false });
|
||||||
|
await alice.waitFor((s) => s.locked === false);
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "ahora sí" });
|
||||||
|
const after = await alice.waitFor((s) => s.notes.length === 1);
|
||||||
|
expect(after.notes[0].text).toBe("ahora sí");
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("votes:clear borra todos los votos", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "x" });
|
||||||
|
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||||
|
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||||
|
await alice.waitFor((s) => s.votes.length === 1);
|
||||||
|
|
||||||
|
alice.emit("votes:clear", {});
|
||||||
|
const cleared = await alice.waitFor((s) => s.votes.length === 0);
|
||||||
|
expect(cleared.votes).toHaveLength(0);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("participant:rename actualiza la autoría de mis notas", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "mía" });
|
||||||
|
await alice.waitFor((s) => s.notes.length === 1 && s.notes[0].authorName === "Alice");
|
||||||
|
|
||||||
|
alice.emit("participant:rename", { name: "Alicia" });
|
||||||
|
const renamed = await alice.waitFor((s) => s.notes[0]?.authorName === "Alicia");
|
||||||
|
expect(renamed.notes[0].authorName).toBe("Alicia");
|
||||||
|
expect(renamed.participants.find((p) => p.id === "alice")?.name).toBe("Alicia");
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("la presencia lista a los participantes conectados", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
const st = await alice.waitFor((s) => s.participants.length === 2);
|
||||||
|
expect(st.participants.map((p) => p.name).sort()).toEqual(["Alice", "Bob"]);
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("action item: marcar hecho", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
alice.emit("actionitem:add", { text: "documentar" });
|
||||||
|
const withItem = await alice.waitFor((s) => s.actionItems.length === 1);
|
||||||
|
const id = withItem.actionItems[0].id;
|
||||||
|
expect(withItem.actionItems[0].done).toBe(false);
|
||||||
|
|
||||||
|
alice.emit("actionitem:toggle", { id, done: true });
|
||||||
|
const done = await alice.waitFor((s) => s.actionItems[0]?.done === true);
|
||||||
|
expect(done.actionItems[0].done).toBe(true);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Notas privadas y publicar", () => {
|
||||||
|
it("note:publishMine publica todas mis notas de la columna", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "uno" });
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "dos" });
|
||||||
|
await alice.waitFor((s) => s.notes.length === 2);
|
||||||
|
|
||||||
|
// Bob recibe las notas pero sin texto (privadas)
|
||||||
|
const bobBefore = await bob.waitFor((s) => s.notes.length === 2);
|
||||||
|
expect(bobBefore.notes.every((n) => n.text === "")).toBe(true);
|
||||||
|
|
||||||
|
alice.emit("note:publishMine", { columnId: colId });
|
||||||
|
|
||||||
|
// guard de longitud: every() es vacuamente true sobre [] -> exigimos las 2 notas
|
||||||
|
const bobAfter = await bob.waitFor((s) => s.notes.length === 2 && s.notes.every((n) => n.text !== ""));
|
||||||
|
expect(bobAfter.notes.map((n) => n.text).sort()).toEqual(["dos", "uno"]);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrupar publica las notas implicadas", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "A" });
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "B" });
|
||||||
|
const st = await alice.waitFor((s) => s.notes.length === 2);
|
||||||
|
const [a, b] = st.notes;
|
||||||
|
|
||||||
|
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||||
|
|
||||||
|
// Bob ve ambas notas con texto (agrupar es público)
|
||||||
|
const bobAfter = await bob.waitFor(
|
||||||
|
(s) => s.groups.length === 1 && s.notes.length === 2 && s.notes.every((n) => n.text !== ""),
|
||||||
|
);
|
||||||
|
expect(bobAfter.notes.map((n) => n.text).sort()).toEqual(["A", "B"]);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("REST", () => {
|
||||||
|
it("lista plantillas predefinidas", async () => {
|
||||||
|
const res = await fetch(`${server.url}/api/templates`);
|
||||||
|
const templates = await res.json();
|
||||||
|
expect(Array.isArray(templates)).toBe(true);
|
||||||
|
expect(templates.find((t: any) => t.id === "mad-sad-glad")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crea un board desde plantilla y devuelve token de moderador", async () => {
|
||||||
|
const board = await createBoard(server.url, { title: "Sprint 1" });
|
||||||
|
expect(board.boardId).toHaveLength(12);
|
||||||
|
expect(board.moderatorToken.length).toBeGreaterThan(10);
|
||||||
|
|
||||||
|
const meta = await (await fetch(`${server.url}/api/boards/${board.boardId}`)).json();
|
||||||
|
expect(meta.title).toBe("Sprint 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crea un board con columnas libres", async () => {
|
||||||
|
const board = await createBoard(server.url, {
|
||||||
|
templateId: undefined,
|
||||||
|
columns: ["Bien", "Mal", "Acciones"],
|
||||||
|
});
|
||||||
|
const md = await (await fetch(`${server.url}/api/boards/${board.boardId}/export`)).text();
|
||||||
|
expect(md).toContain("## Bien");
|
||||||
|
expect(md).toContain("## Mal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fija expiresAt cuando se pasa ttlHours", async () => {
|
||||||
|
const board = await createBoard(server.url, { ttlHours: 2 });
|
||||||
|
expect(board.expiresAt).toBeTruthy();
|
||||||
|
expect(new Date(board.expiresAt!).getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("devuelve 404 para board inexistente", async () => {
|
||||||
|
const res = await fetch(`${server.url}/api/boards/noexiste123`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechaza plantilla inexistente con 400", async () => {
|
||||||
|
const res = await fetch(`${server.url}/api/boards`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ templateId: "nope" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Revelado granular", () => {
|
||||||
|
it("el autor revela solo su propia nota", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "mía" });
|
||||||
|
let bobState = await bob.waitFor((s) => s.notes.length === 1);
|
||||||
|
expect(bobState.notes[0].text).toBe(""); // oculta
|
||||||
|
|
||||||
|
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||||
|
alice.emit("note:reveal", { noteId, revealed: true });
|
||||||
|
|
||||||
|
bobState = await bob.waitFor((s) => s.notes[0]?.text === "mía");
|
||||||
|
expect(bobState.notes[0].text).toBe("mía");
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("los totales de votos se ocultan hasta que el moderador los revela", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "vota esto" });
|
||||||
|
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||||
|
await bob.waitFor((s) => s.notes.length === 1);
|
||||||
|
|
||||||
|
// Bob vota; Alice NO debe ver el voto de Bob (totales ocultos)
|
||||||
|
bob.emit("vote:toggle", { targetType: "note", targetId: noteId });
|
||||||
|
await bob.waitFor((s) => s.votes.some((v) => v.targetId === noteId)); // Bob ve el suyo
|
||||||
|
|
||||||
|
const aliceView = alice.last!;
|
||||||
|
expect(aliceView.votes.filter((v) => v.targetId === noteId)).toHaveLength(0);
|
||||||
|
|
||||||
|
// El moderador revela los votos => Alice ve el de Bob
|
||||||
|
alice.emit("board:revealVotes", { revealed: true, moderatorToken: board.moderatorToken });
|
||||||
|
const revealed = await alice.waitFor((s) => s.votesRevealed && s.votes.length === 1);
|
||||||
|
expect(revealed.votes[0].participantId).toBe("bob");
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cambiar los ajustes del board requiere token de moderador", async () => {
|
||||||
|
const board = await createBoard(server.url, { votesPerUser: 3 });
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
|
||||||
|
// sin token: ignorado
|
||||||
|
alice.emit("board:update", { title: "Hackeado", votesPerUser: 99 });
|
||||||
|
// con token: aplica
|
||||||
|
alice.emit("board:update", { title: "Renombrado", votesPerUser: 7, moderatorToken: board.moderatorToken });
|
||||||
|
|
||||||
|
const updated = await alice.waitFor((s) => s.title === "Renombrado");
|
||||||
|
expect(updated.votesPerUser).toBe(7);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||||
|
|
||||||
|
let server: TestServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await startTestServer();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Socket.IO — flujo de retro", () => {
|
||||||
|
it("oculta las notas de otros hasta revelar", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "secreto de Alice" });
|
||||||
|
|
||||||
|
const bobState = await bob.waitFor((s) => s.notes.length === 1);
|
||||||
|
expect(bobState.notes[0].text).toBe(""); // oculta para Bob
|
||||||
|
expect(bobState.notes[0].authorName).toBe("Alice");
|
||||||
|
|
||||||
|
const aliceState = await alice.waitFor((s) => s.notes.length === 1);
|
||||||
|
expect(aliceState.notes[0].text).toBe("secreto de Alice"); // visible para la autora
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revela las notas a todos cuando el moderador lo pide", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
alice.emit("note:add", { columnId: colId, text: "visible tras revelar" });
|
||||||
|
await bob.waitFor((s) => s.notes.length === 1);
|
||||||
|
|
||||||
|
// Bob (sin token) no puede revelar; Alice (moderadora) sí
|
||||||
|
bob.emit("board:reveal", { revealed: true });
|
||||||
|
alice.emit("board:reveal", { revealed: true, moderatorToken: board.moderatorToken });
|
||||||
|
|
||||||
|
const revealed = await bob.waitFor((s) => s.revealed === true);
|
||||||
|
expect(revealed.notes[0].text).toBe("visible tras revelar");
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respeta el límite de votos por persona", async () => {
|
||||||
|
const board = await createBoard(server.url, { votesPerUser: 2 });
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const colId = alice.last!.columns[0].id;
|
||||||
|
|
||||||
|
for (const txt of ["a", "b", "c"]) alice.emit("note:add", { columnId: colId, text: txt });
|
||||||
|
const withNotes = await alice.waitFor((s) => s.notes.length === 3);
|
||||||
|
const ids = withNotes.notes.map((n) => n.id);
|
||||||
|
const mine = (s: { votes: { participantId: string }[] }) =>
|
||||||
|
s.votes.filter((v) => v.participantId === "alice").length;
|
||||||
|
|
||||||
|
// vota las dos primeras (confirmando cada una)
|
||||||
|
alice.emit("vote:toggle", { noteId: ids[0] });
|
||||||
|
await alice.waitFor((s) => mine(s) === 1);
|
||||||
|
alice.emit("vote:toggle", { noteId: ids[1] });
|
||||||
|
await alice.waitFor((s) => mine(s) === 2);
|
||||||
|
|
||||||
|
// el tercer voto se rechaza (cupo = 2)
|
||||||
|
alice.emit("vote:toggle", { noteId: ids[2] });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(mine(alice.last!)).toBe(2);
|
||||||
|
|
||||||
|
// quitar un voto libera cupo
|
||||||
|
alice.emit("vote:toggle", { noteId: ids[0] });
|
||||||
|
const afterUnvote = await alice.waitFor((s) => mine(s) === 1);
|
||||||
|
expect(afterUnvote.votes).toHaveLength(1);
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comparte el timer (endsAt) con todos los participantes", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
|
||||||
|
alice.emit("timer:start", { seconds: 300, moderatorToken: board.moderatorToken });
|
||||||
|
const bobState = await bob.waitFor((s) => s.timerRunning);
|
||||||
|
expect(bobState.timerEndsAt).toBeTruthy();
|
||||||
|
expect(new Date(bobState.timerEndsAt!).getTime()).toBeGreaterThan(Date.now());
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crea action items visibles para todos", async () => {
|
||||||
|
const board = await createBoard(server.url);
|
||||||
|
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||||
|
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||||
|
|
||||||
|
alice.emit("actionitem:add", { text: "documentar el deploy" });
|
||||||
|
const bobState = await bob.waitFor((s) => s.actionItems.length === 1);
|
||||||
|
expect(bobState.actionItems[0].text).toBe("documentar el deploy");
|
||||||
|
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { beforeEach } from "vitest";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
// Limpia las tablas antes de cada test para aislarlos.
|
||||||
|
const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sql.unsafe("TRUNCATE action_items, votes, notes, groups, columns, boards CASCADE");
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
const TEST_DB =
|
||||||
|
process.env.TEST_DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro_test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globalSetup: "./test/global-setup.ts",
|
||||||
|
setupFiles: "./test/truncate.ts",
|
||||||
|
env: { DATABASE_URL: TEST_DB },
|
||||||
|
fileParallelism: false, // comparten la misma DB; evitamos carreras
|
||||||
|
hookTimeout: 30_000,
|
||||||
|
testTimeout: 20_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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