Waters Boards: self-hosted work management to replace monday.com
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s

Boards with groups, items, subitems and 18 column types; table, kanban,
calendar, timeline, chart and files views; updates with @mentions,
replies and attachments; inbox, My work, search, automations; Google
sign-in restricted to company domains; live updates over SSE; a
monday.com API importer; phone layout and installable PWA; Docker +
Gitea Actions deploy to the NAS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 15:34:47 -04:00
co-authored by Claude Opus 5.5
commit fcf3805626
44 changed files with 7443 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"version": "0.0.1",
"configurations": [
{ "name": "boards", "runtimeExecutable": "node", "runtimeArgs": ["--watch", "server/server.js"], "port": 3100 }
]
}
+81
View File
@@ -0,0 +1,81 @@
# Push to main -> the NAS rebuilds Waters Boards.
#
# Same shape as the checklist app's deploy: runs on the "nas" act_runner (in Docker on the
# NAS itself), copies server/ over the live directory, then recreates the container in place.
#
# ONE-TIME NAS SETUP before the first run (see README.md › Deploy):
# 1. mkdir /volume2/docker/waters-boards and put a filled-in .env there
# 2. add "/volume2/docker/waters-boards" to container.valid_volumes in the act_runner
# config.yaml on the NAS, and restart the runner
# 3. point a reverse-proxy hostname (https) at port 3100 — Google sign-in needs https
# 4. set the repository variable DEPLOY_ENABLED=true (Settings ▸ Actions ▸ Variables).
# Until then this job is skipped, so pushes don't fail before the NAS is ready.
name: Deploy Waters Boards to NAS
on:
push:
branches: [main]
paths:
- 'server/**'
- '.gitea/workflows/deploy.yml'
workflow_dispatch:
jobs:
deploy:
if: ${{ vars.DEPLOY_ENABLED == 'true' }}
runs-on: nas
container:
image: node:22-alpine
volumes:
# Mounted at the SAME path as on the host: docker-compose.yml binds ./data relative to
# the compose file and the daemon resolves that as a host path. (The docker socket is
# already mounted by act_runner — declaring it again breaks the job.)
- /volume2/docker/waters-boards:/volume2/docker/waters-boards
steps:
- name: Install tools
run: apk add --no-cache docker-cli docker-cli-compose rsync
- name: Checkout
uses: actions/checkout@v4
- name: Check the deploy directory is set up
run: |
set -eu
DEST=/volume2/docker/waters-boards
test -f "$DEST/.env" || { echo "REFUSING: $DEST/.env is missing — see README.md › Deploy"; exit 1; }
- name: Copy server files over the live directory
run: |
set -eu
# data/ holds the live database, uploads and backups; .env holds the secrets.
# Neither is in git and neither may ever be written here. No --delete.
rsync -a \
--exclude 'data/' \
--exclude '.env' \
--exclude 'node_modules/' \
server/ /volume2/docker/waters-boards/
- name: Verify the live data and credentials survived
run: |
set -eu
test -f /volume2/docker/waters-boards/.env || { echo "ABORT: .env is gone"; exit 1; }
echo ".env intact"; ls -la /volume2/docker/waters-boards/data 2>/dev/null || echo "(no data/ yet — first deploy)"
- name: Rebuild the container in place
# No 'down' first: an interrupted deploy then leaves the old container serving.
run: docker compose -f /volume2/docker/waters-boards/docker-compose.yml up -d --build --remove-orphans
- name: Healthcheck
run: |
set -eu
for i in $(seq 1 20); do
S=$(docker inspect --format '{{.State.Health.Status}}' waters-boards 2>/dev/null || echo starting)
[ "$S" = "healthy" ] && { echo "container healthy"; exit 0; }
sleep 3
done
echo "container did not report healthy in time:"
docker ps --filter name=waters-boards --format '{{.Names}} {{.Status}}'
docker logs --tail 30 waters-boards
exit 1
+5
View File
@@ -0,0 +1,5 @@
node_modules/
server/data/
server/.env
.DS_Store
*.log
+158
View File
@@ -0,0 +1,158 @@
# Waters Boards
A self-hosted work-management app for the Waters Church team: boards, groups, items, statuses,
people, dates, updates and automations. It covers the parts of monday.com the team uses and
runs on the NAS beside the checklist app, so there's no monthly subscription.
It's one Node server (Express + SQLite) and a front end with no build step. It runs on desktop
browsers and on phones, and a phone can install it to the home screen like an app.
---
## What's in it
| | |
|---|---|
| **Workspaces & folders** | Several workspaces, nested folders, favorites, a searchable sidebar. Drag boards into folders. |
| **Boards** | Colored, collapsible groups you can reorder. Items and subitems, drag-and-drop rows, multi-select bulk actions (duplicate, set status, move, archive, delete). |
| **Column types** | Status (custom labels and colors, with "done" labels), People, Date (optional time), Timeline, Text, Long text, Numbers (with unit), Dropdown, Tags, Checkbox, Rating, Link, Email, Phone, Files, Creation log, Last updated, Item ID. You can resize, reorder, rename, duplicate and change the type of any column. Group footers show summaries. |
| **Views** | Main table, Kanban (drag between lanes), Calendar (drag to reschedule; agenda list on phones), Timeline/Gantt (drag or resize bars), Chart (bar/pie plus KPIs), Files gallery. |
| **Board tools** | Search, person filter, advanced filters, sort, hide columns, CSV import and export (opens in Excel), duplicate or move boards, activity log. |
| **Items** | A side panel (full screen on phones) with Updates (@mentions, replies, likes, attachments, paste images), an Info tab with every column, Files, Activity history, subitems, move to another board. |
| **Automations** | "When status changes to X / a column changes / an item is created / a date arrives" → "move to group / notify / set status / assign / set date / clear / archive". |
| **People** | Google sign-in limited to company domains. The Inbox collects mentions, assignments, replies and automation alerts, and live presence shows who's online. *My work* lists everything assigned to you by due date. A Team directory and admin tools control roles and access. |
| **Live** | Changes appear on everyone's screen right away (server-sent events), with no refresh. |
| **Migration** | One-click import from monday.com through its API: workspaces, folders, boards, columns, groups, items, subitems, updates, replies and files. |
| **Themes** | Light, dark and night, or follow the system setting. |
| **Safety** | Nightly database snapshots (14 kept), files served only to signed-in users, deactivated people are signed out everywhere. |
---
## Run it locally
Needs Node **22.13+** (it uses Node's built-in SQLite, so there's nothing native to compile).
```bash
cd server
npm install
cp .env.example .env # then set DEV_LOGIN=1 for passwordless local sign-in
npm run dev
```
Open <http://localhost:3100>. With `DEV_LOGIN=1`, "Developer sign-in" accepts any
allowed-domain email. That button is disabled in production.
Data goes to `server/data/` (`boards.db`, `uploads/`, `backups/`). The folder is gitignored.
---
## Set up Google sign-in (once)
Google only allows sign-in from **https** addresses (or `localhost`), so the app needs a
hostname behind the NAS reverse proxy, for example `https://boards.watersmedia.org`, set up the
same way as `check.watersmedia.org`.
1. Go to <https://console.cloud.google.com/> and sign in with the church Google Workspace admin account.
2. Create a project (e.g. "Waters Boards").
3. **APIs & Services → OAuth consent screen**: choose **Internal**, so only
`@waterschurch.org` accounts can ever use it. Fill in the app name and support email, then save.
4. **APIs & Services → Credentials → Create credentials → OAuth client ID**
- Application type: **Web application**
- Authorized JavaScript origins: `https://boards.watersmedia.org` (and `http://localhost:3100` for local testing)
- No redirect URIs are needed.
5. Copy the **Client ID** (ends in `.apps.googleusercontent.com`) into `GOOGLE_CLIENT_ID` in `.env`.
The server checks every Google sign-in itself. It rejects any account outside
`ALLOWED_DOMAINS` and any email Google hasn't verified.
Set `ADMIN_PASSWORD` in `.env` as a break-glass login for the `ADMIN_EMAILS` accounts, in case
Google sign-in is ever misconfigured.
---
## Deploy to the NAS
This follows the same pattern as the checklist app: Docker on the NAS, and Gitea Actions
redeploys on every push to `main` that touches `server/`.
**One-time setup on the NAS** (`10.1.1.5`):
```bash
mkdir -p /volume2/docker/waters-boards
cp server/.env.example /volume2/docker/waters-boards/.env # then fill it in
```
1. In the act_runner `config.yaml` on the NAS, add `/volume2/docker/waters-boards` to
`container.valid_volumes`, then restart the runner. The runner refuses host mounts that
aren't listed there.
2. Add a reverse-proxy rule: `https://boards.watersmedia.org` → `http://localhost:3100`.
Allow WebSocket/streaming headers, because the live updates use a long-lived HTTP response.
3. In the Gitea repo, set **Settings → Actions → Variables → `DEPLOY_ENABLED` = `true`**. Until it's
set, the deploy job is skipped, so pushes made before the NAS was ready don't show as failures.
4. Push to `main`, or run the workflow by hand from the Gitea Actions tab.
The workflow (`.gitea/workflows/deploy.yml`) never writes `data/` or `.env`. It rebuilds the
container in place without taking it down first, and fails loudly if `.env` is missing.
**Manual deploy**, without Gitea: copy `server/` (minus `data/`, `.env` and `node_modules/`) to the
NAS folder, then run `docker compose up -d --build` there.
---
## Bring your monday.com data over
1. Sign in as an admin and open **Admin → Import from monday.com**.
2. In monday.com, click your avatar → **Developers** → **My access tokens** → copy.
3. Paste the token, click **Load boards**, pick boards (all are selected by default), then click **Import**.
People are matched by email. Anyone who isn't signed in yet gets a placeholder, and their
assignments carry over when they first sign in with Google. You can re-run the import: boards
that were already imported are skipped. The token is used only for that run and is never
stored.
Subitems get their own columns in monday. Here, they show the parent board's columns, so
subitem values land in the parent column with the same title. Any value without a matching
column is kept as a note in the subitem's updates. Mirror, formula and connect-board columns
come across as their text value.
---
## Installing on phones
Open the site in Safari (iPhone) or Chrome (Android), then choose **Share → Add to Home
Screen** or **Install app**. It opens full screen with a bottom tab bar (Home, My work, Boards,
Inbox, Search). Pickers open as bottom sheets.
---
## Backups
- Every night after 2am the app writes `data/backups/boards-YYYY-MM-DD.db` and keeps 14.
- For off-site copies, back up the whole `data/` folder (database + `uploads/`) the same way
the checklist data is backed up.
- To restore, stop the container, copy a snapshot over `data/boards.db`, and start it again.
---
## Layout
```
server/
server.js Express app, admin import routes, nightly backup
src/db.js SQLite schema (node:sqlite) and helpers
src/auth.js Google ID-token sign-in, sessions, admin password, dev login
src/api.js REST API — boards, columns, groups, items, updates, files, views, automations, search
src/model.js Board loading, value formatting, activity log, notifications
src/automations.js Automation engine + daily date trigger
src/realtime.js Server-sent events
src/importMonday.js monday.com GraphQL importer
src/templates.js Board templates and default labels
public/ Front end (Preact + htm, no build step)
js/app.js Shell, routing, sign-in
js/board.js Board header, views, toolbar, filters
js/table.js Main table (and the phone card list)
js/cells.js Column types: cells, pickers, summaries
js/item.js Item panel: updates, info, files, activity
js/kanban.js · calendar.js · timeline.js · chart.js
js/pages.js Home, My work, Inbox, Search, Team, Admin
```
+25
View File
@@ -0,0 +1,25 @@
# Copy to .env next to docker-compose.yml and fill in. Never commit .env.
PORT=3100
# Google sign-in — the OAuth "Web application" client ID from Google Cloud Console.
# See README.md › "Set up Google sign-in". Leave empty to disable Google sign-in.
GOOGLE_CLIENT_ID=
# Only Google accounts on these domains can sign in (comma-separated).
ALLOWED_DOMAINS=waterschurch.org
# These people are always admins (comma-separated). The very first person to sign in
# also becomes an admin.
ADMIN_EMAILS=you@waterschurch.org
# Optional break-glass password for the ADMIN_EMAILS accounts, for when Google sign-in
# is not set up yet or is broken. Use something long. Leave empty to disable.
ADMIN_PASSWORD=
# Name of the first workspace created on an empty database.
ORG_NAME=Waters Church
# Local development only: passwordless sign-in for any allowed-domain email.
# Ignored when NODE_ENV=production (which the Docker image sets).
# DEV_LOGIN=1
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY src ./src
COPY public ./public
# Database, uploads and nightly backups live here; mount a volume in compose.
RUN mkdir -p /app/data
ENV DATA_DIR=/app/data
ENV NODE_ENV=production
ENV PORT=3100
EXPOSE 3100
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3100/healthz >/dev/null 2>&1 || exit 1
CMD ["node", "--disable-warning=ExperimentalWarning", "server.js"]
+14
View File
@@ -0,0 +1,14 @@
services:
boards:
build: .
image: waters-boards:latest
container_name: waters-boards
restart: unless-stopped
ports:
# Host port : container port. 3000 is the checklist app, so this uses 3100.
- "3100:3100"
env_file:
- .env
volumes:
# Database (boards.db), uploaded files and nightly backups.
- ./data:/app/data
+1188
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "waters-boards",
"version": "1.0.0",
"description": "Self-hosted boards, tasks and projects for the Waters Church team",
"main": "server.js",
"scripts": {
"start": "node --disable-warning=ExperimentalWarning server.js",
"dev": "node --disable-warning=ExperimentalWarning --watch server.js"
},
"license": "UNLICENSED",
"dependencies": {
"cookie-parser": "^1.4.7",
"dotenv": "^16.6.1",
"express": "^4.22.3",
"google-auth-library": "^11.3.0",
"multer": "^2.4.0"
},
"devDependencies": {
"htm": "^3.1.1"
},
"private": true,
"engines": {
"node": ">=22.13"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#181b34"/>
<rect x="112" y="112" width="76" height="288" rx="38" fill="#579bfc"/>
<rect x="218" y="112" width="76" height="210" rx="38" fill="#00c875"/>
<rect x="324" y="112" width="76" height="140" rx="38" fill="#fdab3d"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Boards" />
<title>Waters Boards</title>
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/icon-180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&display=swap" />
<link rel="stylesheet" href="/styles.css" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/js/app.js"></script>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
// Board/item mutations: optimistic local update, then the server call.
import { api } from './api.js';
import { state, mutateBoard, serverCall, loadBoard, loadTree, navigate, toast, boardPath } from './store.js';
import { confirmDialog, promptDialog } from './ui.js';
const B = (boardId) => state.boardData[boardId];
export function setValue(boardId, itemId, colId, value) {
mutateBoard(boardId, (b) => {
const it = b.items.find((i) => i.id === itemId);
if (!it) return;
if (value === null || value === '' || (Array.isArray(value) && !value.length)) delete it.vals[colId];
else it.vals[colId] = value;
it.updated_at = Date.now();
});
return serverCall(boardId, api.patch(`/api/items/${itemId}`, { vals: { [colId]: value } }));
}
export function renameItem(boardId, itemId, name) {
if (!name.trim()) return;
mutateBoard(boardId, (b) => { const it = b.items.find((i) => i.id === itemId); if (it) it.name = name; });
return serverCall(boardId, api.patch(`/api/items/${itemId}`, { name }));
}
export async function createItem(boardId, { group_id, parent_id, name, vals = {}, top } = {}) {
const b = B(boardId);
const tmp = { id: 'tmp' + Math.random(), board_id: boardId, group_id: parent_id ? null : group_id, parent_id: parent_id || null, name, vals, position: top ? -1e9 : 1e9, created_at: Date.now(), updated_at: Date.now() };
if (b) { b.items.push(tmp); }
mutateBoard(boardId, () => {});
try {
const it = await api.post(`/api/boards/${boardId}/items`, { group_id, parent_id, name, vals, top });
mutateBoard(boardId, (bd) => { const i = bd.items.findIndex((x) => x.id === tmp.id); if (i >= 0) bd.items[i] = it; else bd.items.push(it); });
return it;
} catch (e) {
toast(e.message, 'error'); loadBoard(boardId); throw e;
}
}
export async function deleteItems(boardId, ids, { ask = true } = {}) {
if (ask && !(await confirmDialog({ title: ids.length > 1 ? `Delete ${ids.length} items?` : 'Delete this item?', body: 'Its subitems, updates and files are deleted too. This cannot be undone.' }))) return false;
mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => !ids.includes(i.id) && !ids.includes(i.parent_id)); });
if (state.route.itemId && ids.includes(state.route.itemId)) navigate(boardPath(boardId, { viewId: state.route.viewId }));
await serverCall(boardId, ids.length === 1 ? api.del(`/api/items/${ids[0]}`) : api.post('/api/items/bulk', { ids, action: 'delete' }));
toast(ids.length > 1 ? `${ids.length} items deleted` : 'Item deleted');
return true;
}
export async function archiveItems(boardId, ids) {
mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => !ids.includes(i.id) && !ids.includes(i.parent_id)); });
await serverCall(boardId, api.post('/api/items/bulk', { ids, action: 'archive' }));
toast(ids.length > 1 ? `${ids.length} items archived` : 'Item archived');
}
export async function duplicateItems(boardId, ids) {
await serverCall(boardId, ids.length === 1 ? api.post(`/api/items/${ids[0]}/duplicate`) : api.post('/api/items/bulk', { ids, action: 'duplicate' }));
loadBoard(boardId);
}
export async function moveItem(boardId, itemId, { group_id, position }) {
mutateBoard(boardId, (b) => {
const it = b.items.find((i) => i.id === itemId);
if (it) { if (group_id) it.group_id = group_id; if (position != null) it.position = position; }
});
return serverCall(boardId, api.patch(`/api/items/${itemId}`, { group_id, position }));
}
export async function moveItemsToGroup(boardId, ids, group_id) {
mutateBoard(boardId, (b) => { for (const it of b.items) if (ids.includes(it.id)) it.group_id = group_id; });
await serverCall(boardId, api.post('/api/items/bulk', { ids, action: 'move', group_id }));
}
export async function moveItemToBoard(boardId, itemId, targetBoardId) {
const target = state.boardData[targetBoardId] || await loadBoard(targetBoardId);
const g = target.groups[0];
if (!g) return;
mutateBoard(boardId, (b) => { b.items = b.items.filter((i) => i.id !== itemId && i.parent_id !== itemId); });
await serverCall(boardId, api.patch(`/api/items/${itemId}`, { group_id: g.id }));
toast(`Moved to ${target.board.name}`);
loadBoard(targetBoardId);
}
// Position between two neighbours (fractional indexing).
export function between(a, b) {
if (a == null && b == null) return 1;
if (a == null) return b - 1;
if (b == null) return a + 1;
return (a + b) / 2;
}
// ─── Groups ──────────────────────────────────────────────
export async function addGroup(boardId, { top = true } = {}) {
const r = await serverCall(boardId, api.post(`/api/boards/${boardId}/groups`, { title: 'New Group', top }));
await loadBoard(boardId);
return r.id;
}
export function patchGroup(boardId, groupId, patch) {
mutateBoard(boardId, (b) => { const g = b.groups.find((x) => x.id === groupId); if (g) Object.assign(g, patch); });
return serverCall(boardId, api.patch(`/api/groups/${groupId}`, patch));
}
export async function deleteGroup(boardId, groupId) {
const b = B(boardId);
const g = b.groups.find((x) => x.id === groupId);
const n = b.items.filter((i) => i.group_id === groupId).length;
if (!(await confirmDialog({ title: `Delete "${g.title}"?`, body: n ? `This deletes the group and its ${n} item${n === 1 ? '' : 's'}.` : 'This group is empty.' }))) return;
mutateBoard(boardId, (bd) => { bd.groups = bd.groups.filter((x) => x.id !== groupId); bd.items = bd.items.filter((i) => i.group_id !== groupId); });
await serverCall(boardId, api.del(`/api/groups/${groupId}`));
}
// ─── Columns ─────────────────────────────────────────────
export async function addColumn(boardId, type, { after, title } = {}) {
const r = await serverCall(boardId, api.post(`/api/boards/${boardId}/columns`, { type, after, title }));
await loadBoard(boardId);
return r.id;
}
export function patchColumn(boardId, colId, patch) {
mutateBoard(boardId, (b) => { const c = b.columns.find((x) => x.id === colId); if (c) Object.assign(c, patch); });
return serverCall(boardId, api.patch(`/api/columns/${colId}`, patch));
}
export async function deleteColumn(boardId, colId) {
const c = B(boardId).columns.find((x) => x.id === colId);
if (!(await confirmDialog({ title: `Delete column "${c.title}"?`, body: 'All values in this column are deleted.' }))) return;
mutateBoard(boardId, (b) => { b.columns = b.columns.filter((x) => x.id !== colId); });
await serverCall(boardId, api.del(`/api/columns/${colId}`));
}
// ─── Boards / workspace structure ────────────────────────
export async function renameBoard(boardId, name) {
mutateBoard(boardId, (b) => { b.board.name = name; });
const tb = state.boards.find((b) => b.id === boardId); if (tb) tb.name = name;
await serverCall(boardId, api.patch(`/api/boards/${boardId}`, { name }));
}
export async function deleteBoard(boardId) {
const b = state.boards.find((x) => x.id === boardId);
if (!(await confirmDialog({ title: `Delete board "${b?.name}"?`, body: 'Every item, update and file on it is deleted. This cannot be undone.' }))) return;
await api.del(`/api/boards/${boardId}`);
delete state.boardData[boardId];
if (state.route.boardId === boardId) navigate('/');
await loadTree();
toast('Board deleted');
}
export async function duplicateBoard(boardId) {
const r = await api.post(`/api/boards/${boardId}/duplicate`, {});
await loadTree();
navigate(boardPath(r.id));
}
export async function newFolder(workspace_id, parent_id) {
const name = await promptDialog({ title: 'New folder', placeholder: 'Folder name', confirm: 'Create' });
if (!name) return;
await api.post('/api/folders', { workspace_id, parent_id, name });
await loadTree();
}
export async function renameFolder(f) {
const name = await promptDialog({ title: 'Rename folder', value: f.name });
if (!name) return;
await api.patch(`/api/folders/${f.id}`, { name });
await loadTree();
}
export async function deleteFolder(f) {
if (!(await confirmDialog({ title: `Delete folder "${f.name}"?`, body: 'Boards inside are kept and moved up a level.' }))) return;
await api.del(`/api/folders/${f.id}`);
await loadTree();
}
export async function toggleFavorite(boardId) {
const on = !state.favorites.includes(boardId);
state.favorites = on ? [...state.favorites, boardId] : state.favorites.filter((x) => x !== boardId);
mutateBoard(boardId, (b) => { b.favorite = on; });
await api.post(`/api/boards/${boardId}/favorite`, { on });
}
+44
View File
@@ -0,0 +1,44 @@
// Fetch wrapper + live event stream.
export const clientId = Math.random().toString(36).slice(2);
export class ApiError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
async function request(method, url, body, opts = {}) {
const headers = { 'X-Client-Id': clientId };
let payload = body;
if (body !== undefined && !(body instanceof FormData) && typeof body !== 'string') {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
} else if (typeof body === 'string') headers['Content-Type'] = opts.contentType || 'text/plain';
const res = await fetch(url, { method, headers, body: payload, credentials: 'same-origin' });
if (res.status === 401 && !url.startsWith('/api/auth')) {
window.dispatchEvent(new CustomEvent('wb:signed-out'));
}
const ct = res.headers.get('content-type') || '';
const data = ct.includes('json') ? await res.json() : await res.text();
if (!res.ok) throw new ApiError(res.status, data?.error || res.statusText);
return data;
}
export const api = {
get: (u) => request('GET', u),
post: (u, b = {}, o) => request('POST', u, b, o),
patch: (u, b = {}) => request('PATCH', u, b),
del: (u) => request('DELETE', u),
};
let es = null;
const listeners = new Set();
export function onEvent(fn) { listeners.add(fn); return () => listeners.delete(fn); }
export function connectEvents() {
if (es) return;
es = new EventSource(`/api/events?client=${clientId}`);
es.onmessage = (m) => {
let ev; try { ev = JSON.parse(m.data); } catch { return; }
for (const fn of listeners) fn(ev);
};
es.onopen = () => { for (const fn of listeners) fn({ type: 'reconnected' }); };
}
export function disconnectEvents() { es?.close(); es = null; }
+138
View File
@@ -0,0 +1,138 @@
// Entry point: sign-in gate, app shell, routing, overlays, mobile tab bar.
import { html, render, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
import { api, connectEvents } from './api.js';
import { state, update, useStore, navigate, loadBootstrap, applyTheme } from './store.js';
import { Icon, PopoverHost, ModalHost, Spinner, useMobile } from './ui.js';
import { Sidebar } from './sidebar.js';
import { BoardPage } from './board.js';
import { HomePage, MyWorkPage, InboxPage, SearchPage, PeoplePage, AdminPage, WorkspacePage } from './pages.js';
applyTheme();
function App() {
const s = useStore();
const [phase, setPhase] = useState('loading'); // loading | signin | ready
useEffect(() => {
loadBootstrap().then(() => { setPhase('ready'); connectEvents(); }).catch(() => setPhase('signin'));
const out = () => setPhase('signin');
window.addEventListener('wb:signed-out', out);
return () => window.removeEventListener('wb:signed-out', out);
}, []);
// Keyboard: "/" or Cmd/Ctrl+K opens search
useEffect(() => {
const fn = (e) => {
if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable) return;
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { e.preventDefault(); navigate('/search'); }
};
document.addEventListener('keydown', fn);
return () => document.removeEventListener('keydown', fn);
}, []);
if (phase === 'loading') return html`<div class="splash"><span class="logo-mark big"><i /><i /><i /></span></div>`;
if (phase === 'signin') return html`<${SignIn} onDone=${async () => { await loadBootstrap(); connectEvents(); setPhase('ready'); }} />`;
return html`<div class="app ${s.sidebarCollapsed ? 'nav-collapsed' : ''}">
<${Sidebar} />
<main class="main"><${Route} /></main>
<${MobileTabs} />
<${PopoverHost} />
<${ModalHost} />
<${Toasts} />
</div>`;
}
function Route() {
const r = useStore().route;
useEffect(() => {
const b = r.boardId && state.boards.find((x) => x.id === r.boardId);
document.title = b ? `${b.name} · Waters Boards` : 'Waters Boards';
}, [r.name, r.boardId, state.boards.length]);
switch (r.name) {
case 'board': return html`<${BoardPage} key=${r.boardId} boardId=${r.boardId} viewId=${r.viewId} itemId=${r.itemId} />`;
case 'my-work': return html`<${MyWorkPage} key=${r.q?.user || 'me'} />`;
case 'inbox': return html`<${InboxPage} />`;
case 'search': return html`<${SearchPage} />`;
case 'people': return html`<${PeoplePage} />`;
case 'admin': return html`<${AdminPage} />`;
case 'workspace': return html`<${WorkspacePage} workspaceId=${r.workspaceId} />`;
default: return html`<${HomePage} />`;
}
}
function MobileTabs() {
const s = useStore();
const mobile = useMobile();
if (!mobile || s.route.itemId) return null;
const r = s.route.name;
const Tab = ({ icon, label, to, active, badge, onClick }) => html`<button class="mtab ${active ? 'active' : ''}" onClick=${onClick || (() => navigate(to))}>
<${Icon} name=${icon} size=${22} />${badge ? html`<span class="rail-badge">${badge > 99 ? '99+' : badge}</span>` : null}<span>${label}</span></button>`;
return html`<nav class="mobile-tabs">
<${Tab} icon="home" label="Home" to="/" active=${r === 'home'} />
<${Tab} icon="work" label="My work" to="/my-work" active=${r === 'my-work'} />
<${Tab} icon="board" label="Boards" active=${r === 'board' || s.sidebarOpen} onClick=${() => update({ sidebarOpen: true })} />
<${Tab} icon="bell" label="Inbox" to="/inbox" active=${r === 'inbox'} badge=${s.unread} />
<${Tab} icon="search" label="Search" to="/search" active=${r === 'search'} />
</nav>`;
}
function Toasts() {
const s = useStore();
return html`<div class="toasts">${s.toasts.map((t) => html`<div key=${t.id} class="toast ${t.kind}">${t.text}
${t.action ? html`<button class="link" onClick=${t.action.onClick}>${t.action.label}</button>` : null}</div>`)}</div>`;
}
// ─── Sign in ─────────────────────────────────────────────
function SignIn({ onDone }) {
const [cfg, setCfg] = useState(null);
const [err, setErr] = useState('');
const [mode, setMode] = useState('google');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const btn = useRef();
useEffect(() => { api.get('/api/auth/config').then(setCfg).catch(() => setCfg({})); }, []);
useEffect(() => {
if (!cfg?.googleClientId || mode !== 'google') return;
let tries = 0;
const init = () => {
if (!window.google?.accounts?.id) { if (tries++ < 50) setTimeout(init, 100); else setErr("Couldn't load Google sign-in. Check your connection."); return; }
window.google.accounts.id.initialize({
client_id: cfg.googleClientId,
callback: async (resp) => {
setErr('');
try { await api.post('/api/auth/google', { credential: resp.credential }); onDone(); }
catch (e) { setErr(e.message); }
},
hd: cfg.allowedDomains?.length === 1 ? cfg.allowedDomains[0] : undefined,
ux_mode: 'popup',
});
window.google.accounts.id.renderButton(btn.current, { theme: 'outline', size: 'large', text: 'continue_with', shape: 'pill', width: 280 });
};
init();
}, [cfg, mode]);
const submitPassword = async (e) => {
e.preventDefault(); setErr('');
try { await api.post(mode === 'dev' ? '/api/auth/dev' : '/api/auth/password', { email, password, name: email.split('@')[0] }); onDone(); }
catch (e2) { setErr(e2.message); }
};
if (!cfg) return html`<div class="splash"><${Spinner} /></div>`;
return html`<div class="signin">
<div class="signin-card">
<span class="logo-mark big"><i /><i /><i /></span>
<h1>Waters Boards</h1>
<p class="muted">Sign in with your ${cfg.allowedDomains?.map((d) => '@' + d).join(' or ')} account</p>
${mode === 'google' ? html`
${cfg.googleClientId ? html`<div class="gbtn" ref=${btn} />` : html`<p class="warn-note">Google sign-in isn't set up on this server yet (GOOGLE_CLIENT_ID is empty). An admin can still sign in with a password.</p>`}
${cfg.passwordLogin ? html`<button class="link small" onClick=${() => setMode('password')}>Admin sign-in with password</button>` : null}
${cfg.devLogin ? html`<button class="link small" onClick=${() => setMode('dev')}>Developer sign-in (local only)</button>` : null}
` : html`<form class="signin-form" onSubmit=${submitPassword}>
<input class="input" type="email" placeholder="Email" value=${email} onInput=${(e) => setEmail(e.target.value)} autofocus required />
${mode === 'password' ? html`<input class="input" type="password" placeholder="Password" value=${password} onInput=${(e) => setPassword(e.target.value)} required />` : null}
<button class="btn primary">Sign in</button>
<button type="button" class="link small" onClick=${() => setMode('google')}>Back to Google sign-in</button>
</form>`}
${err ? html`<p class="error-note">${err}</p>` : null}
</div>
</div>`;
}
render(html`<${App} />`, document.getElementById('app'));
if ('serviceWorker' in navigator && location.protocol === 'https:') navigator.serviceWorker.register('/sw.js').catch(() => {});
+110
View File
@@ -0,0 +1,110 @@
// Automations: "When … then …" recipes for a board.
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, useStore, loadBoard, toast } from './store.js';
import { Icon, ModalHeader, confirmDialog } from './ui.js';
const TRIGGERS = {
status_change: 'When a status changes',
column_change: 'When a column changes',
item_created: 'When an item is created',
date_arrives: 'When a date arrives',
};
const ACTIONS = {
notify: 'notify someone',
move_to_group: 'move the item to a group',
set_status: 'set a status',
assign: 'assign a person',
set_date_today: "set a date to today",
clear_column: 'clear a column',
archive: 'archive the item',
};
export function AutomationsModal({ boardId }) {
const s = useStore();
const b = s.boardData[boardId];
const [adding, setAdding] = useState(false);
if (!b) return null;
const toggle = async (a) => { await api.patch(`/api/automations/${a.id}`, { enabled: !a.enabled }); loadBoard(boardId); };
const remove = async (a) => { if (await confirmDialog({ title: 'Delete this automation?' })) { await api.del(`/api/automations/${a.id}`); loadBoard(boardId); } };
return html`<div class="dialog automations"><${ModalHeader} title="Automations" sub="Let the board do the repetitive work." />
${b.automations.map((a) => html`<div key=${a.id} class="auto-row ${a.enabled ? '' : 'off'}">
<${Icon} name="bolt" />
<span class="auto-text">${describe(b, a.trigger, a.action)}</span>
<label class="switch"><input type="checkbox" checked=${a.enabled} onChange=${() => toggle(a)} /><span /></label>
<button class="icon-btn" onClick=${() => remove(a)}><${Icon} name="trash" /></button>
</div>`)}
${!b.automations.length && !adding ? html`<p class="muted">No automations yet. Some ideas: notify the owner when an item is stuck, move items to "Done" when their status is Done, remind people on their due date.</p>` : null}
${adding ? html`<${Builder} board=${b} done=${() => setAdding(false)} />` : html`<button class="btn primary" onClick=${() => setAdding(true)}><${Icon} name="plus" /> Create automation</button>`}
</div>`;
}
function describe(b, t, a) {
const col = (id) => b.columns.find((c) => c.id === id)?.title || '?';
const label = (cid, lid) => b.columns.find((c) => c.id === cid)?.settings.labels?.find((l) => l.id === lid)?.name || 'anything';
const group = (id) => b.groups.find((g) => g.id === id)?.title || '?';
const user = (id) => state.usersById[id]?.name || '?';
let when = '';
if (t.type === 'status_change') when = html`When <b>${col(t.column_id)}</b> changes to <b>${t.to ? label(t.column_id, t.to) : 'anything'}</b>`;
else if (t.type === 'column_change') when = html`When <b>${col(t.column_id)}</b> changes`;
else if (t.type === 'item_created') when = html`When an item is created`;
else if (t.type === 'date_arrives') when = html`When <b>${col(t.column_id)}</b> arrives`;
let then = '';
if (a.type === 'notify') then = html`notify <b>${a.to === 'user' ? user(a.user_id) : `people in ${col(a.column_id)}`}</b>`;
else if (a.type === 'move_to_group') then = html`move item to <b>${group(a.group_id)}</b>`;
else if (a.type === 'set_status') then = html`set <b>${col(a.column_id)}</b> to <b>${label(a.column_id, a.label)}</b>`;
else if (a.type === 'assign') then = html`assign <b>${user(a.user_id)}</b> in <b>${col(a.column_id)}</b>`;
else if (a.type === 'set_date_today') then = html`set <b>${col(a.column_id)}</b> to today`;
else if (a.type === 'clear_column') then = html`clear <b>${col(a.column_id)}</b>`;
else if (a.type === 'archive') then = html`archive the item`;
return html`${when}, ${then}`;
}
function Builder({ board, done }) {
const cols = (types) => board.columns.filter((c) => types.includes(c.type));
const [t, setT] = useState({ type: 'status_change', column_id: cols(['status'])[0]?.id, to: '' });
const [a, setA] = useState({ type: 'notify', to: 'people_column', column_id: cols(['people'])[0]?.id });
const statusCol = board.columns.find((c) => c.id === t.column_id);
const save = async () => {
try {
await api.post(`/api/boards/${board.board.id}/automations`, { trigger: { ...t, to: t.to || null }, action: a });
loadBoard(board.board.id); done();
} catch (e) { toast(e.message, 'error'); }
};
const colSelect = (types, value, onChange) => html`<select class="input sm" value=${value || ''} onChange=${(e) => onChange(e.target.value)}>
<option value="" disabled>column…</option>${cols(types).map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>`;
const valid = (t.type === 'item_created' || t.column_id) && (
a.type === 'archive' || (a.type === 'notify' && (a.to === 'user' ? a.user_id : a.column_id)) || (a.type === 'move_to_group' && a.group_id) ||
(a.type === 'set_status' && a.column_id && a.label) || (a.type === 'assign' && a.column_id && a.user_id) || (['set_date_today', 'clear_column'].includes(a.type) && a.column_id));
return html`<div class="auto-builder">
<div class="auto-sentence">
<select class="input sm" value=${t.type} onChange=${(e) => {
const type = e.target.value;
const first = { status_change: cols(['status'])[0]?.id, date_arrives: cols(['date', 'timeline'])[0]?.id, column_change: board.columns[0]?.id }[type];
setT({ type, column_id: first, to: '' });
}}>${Object.entries(TRIGGERS).map(([k, l]) => html`<option value=${k}>${l}</option>`)}</select>
${t.type === 'status_change' ? html`${colSelect(['status'], t.column_id, (v) => setT({ ...t, column_id: v, to: '' }))} <span>changes to</span>
<select class="input sm" value=${t.to} onChange=${(e) => setT({ ...t, to: e.target.value })}><option value="">anything</option>
${(statusCol?.settings.labels || []).map((l) => html`<option value=${l.id}>${l.name}</option>`)}</select>` : null}
${t.type === 'column_change' ? colSelect(board.columns.map((c) => c.type), t.column_id, (v) => setT({ ...t, column_id: v })) : null}
${t.type === 'date_arrives' ? colSelect(['date', 'timeline'], t.column_id, (v) => setT({ ...t, column_id: v })) : null}
</div>
<div class="auto-sentence"><span>then</span>
<select class="input sm" value=${a.type} onChange=${(e) => setA({ type: e.target.value })}>${Object.entries(ACTIONS).map(([k, l]) => html`<option value=${k}>${l}</option>`)}</select>
${a.type === 'notify' ? html`
<select class="input sm" value=${a.to || 'people_column'} onChange=${(e) => setA({ ...a, to: e.target.value })}><option value="people_column">people in column</option><option value="user">a specific person</option></select>
${a.to === 'user' ? html`<select class="input sm" value=${a.user_id || ''} onChange=${(e) => setA({ ...a, user_id: e.target.value })}><option value="" disabled>person…</option>${state.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.name}</option>`)}</select>`
: colSelect(['people'], a.column_id, (v) => setA({ ...a, column_id: v }))}
<input class="input sm grow" placeholder="Message (optional)" value=${a.text || ''} onInput=${(e) => setA({ ...a, text: e.target.value })} />` : null}
${a.type === 'move_to_group' ? html`<select class="input sm" value=${a.group_id || ''} onChange=${(e) => setA({ ...a, group_id: e.target.value })}><option value="" disabled>group…</option>${board.groups.map((g) => html`<option value=${g.id}>${g.title}</option>`)}</select>` : null}
${a.type === 'set_status' ? html`${colSelect(['status'], a.column_id, (v) => setA({ ...a, column_id: v, label: '' }))}
<select class="input sm" value=${a.label || ''} onChange=${(e) => setA({ ...a, label: e.target.value })}><option value="" disabled>label…</option>
${(board.columns.find((c) => c.id === a.column_id)?.settings.labels || []).map((l) => html`<option value=${l.id}>${l.name}</option>`)}</select>` : null}
${a.type === 'assign' ? html`${colSelect(['people'], a.column_id, (v) => setA({ ...a, column_id: v }))}
<select class="input sm" value=${a.user_id || ''} onChange=${(e) => setA({ ...a, user_id: e.target.value })}><option value="" disabled>person…</option>${state.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.name}</option>`)}</select>` : null}
${a.type === 'set_date_today' ? colSelect(['date'], a.column_id, (v) => setA({ ...a, column_id: v })) : null}
${a.type === 'clear_column' ? colSelect(board.columns.map((c) => c.type), a.column_id, (v) => setA({ ...a, column_id: v })) : null}
</div>
<div class="modal-actions"><button class="btn ghost" onClick=${done}>Cancel</button><button class="btn primary" disabled=${!valid} onClick=${save}>Create automation</button></div>
</div>`;
}
+349
View File
@@ -0,0 +1,349 @@
// Board page: header, view tabs, toolbar (search / person / filter / sort / hide) and the active view.
import { html, useState, useEffect, useMemo } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, loadBoard, loadTree, navigate, boardPath, mutateBoard, serverCall, toast } from './store.js';
import { Icon, Avatar, AvatarStack, InlineEdit, menu, openPopover, openModal, closeModal, ModalHeader, Spinner, Empty, promptDialog, confirmDialog, useMobile } from './ui.js';
import { COLUMN_TYPES, cellText } from './cells.js';
import * as A from './actions.js';
import { TableView } from './table.js';
import { KanbanView } from './kanban.js';
import { CalendarView } from './calendar.js';
import { TimelineView } from './timeline.js';
import { ChartView, FilesView } from './chart.js';
import { ItemPanel } from './item.js';
import { AutomationsModal } from './automations.js';
import { ActivityModal, ImportCsvModal } from './boardModals.js';
export const VIEW_TYPES = {
table: { label: 'Table', icon: 'table', desc: 'Groups, columns and items' },
kanban: { label: 'Kanban', icon: 'kanban', desc: 'Cards by status' },
calendar: { label: 'Calendar', icon: 'calendar', desc: 'Items by date' },
timeline: { label: 'Timeline', icon: 'timeline', desc: 'Gantt-style schedule' },
chart: { label: 'Chart', icon: 'chart', desc: 'Counts by status, person…' },
files: { label: 'Files', icon: 'paperclip', desc: 'Every file on the board' },
};
// Ephemeral per-board filter state (not shared with others).
const filters = {};
export function getFilter(boardId) { return (filters[boardId] ||= { q: '', people: [], rules: [], sort: null }); }
function setFilter(boardId, patch) { Object.assign(getFilter(boardId), patch); update(); }
const activeFilterCount = (f) => (f.q ? 1 : 0) + (f.people.length ? 1 : 0) + f.rules.length;
// Returns top-level items passing the filter. A parent stays visible if any subitem matches.
export function filterItems(board, f) {
const subs = {};
for (const it of board.items) if (it.parent_id) (subs[it.parent_id] ||= []).push(it);
const q = f.q.trim().toLowerCase();
const peopleCols = board.columns.filter((c) => c.type === 'people');
const matches = (it) => {
if (q) {
const hay = [it.name, ...board.columns.map((c) => cellText(board, c, it))].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
if (f.people.length && !peopleCols.some((c) => (it.vals[c.id] || []).some((u) => f.people.includes(u)))) return false;
for (const r of f.rules) {
const col = board.columns.find((c) => c.id === r.colId);
if (!col) continue;
const v = it.vals[col.id];
const empty = v == null || v === '' || (Array.isArray(v) && !v.length);
if (r.op === 'empty' && !empty) return false;
if (r.op === 'not_empty' && empty) return false;
if (r.op === 'is' || r.op === 'is_not') {
const vs = Array.isArray(v) ? v : [v ?? '__empty'];
const hit = r.values.some((x) => vs.includes(x) || (x === '__empty' && empty));
if (r.op === 'is' && !hit) return false;
if (r.op === 'is_not' && hit) return false;
}
if (r.op === 'contains' && !cellText(board, col, it).toLowerCase().includes(String(r.values[0] || '').toLowerCase())) return false;
if (r.op === 'before' || r.op === 'after') {
const d = col.type === 'timeline' ? v?.from : v && String(v).slice(0, 10);
if (!d) return false;
if (r.op === 'before' && !(d < r.values[0])) return false;
if (r.op === 'after' && !(d > r.values[0])) return false;
}
}
return true;
};
const anyFilter = activeFilterCount(f) > 0;
let out = board.items.filter((it) => !it.parent_id && (!anyFilter || matches(it) || (subs[it.id] || []).some(matches)));
if (f.sort) {
const col = f.sort.colId === '__name' ? null : board.columns.find((c) => c.id === f.sort.colId);
const key = (it) => {
if (!col) return it.name.toLowerCase();
const v = it.vals[col.id];
if (col.type === 'number' || col.type === 'rating') return v ?? -Infinity;
if (col.type === 'date') return v || '';
if (col.type === 'timeline') return v?.from || '';
if (col.type === 'status') { const i = (col.settings.labels || []).findIndex((l) => l.id === v); return i < 0 ? 999 : i; }
if (col.type === 'created') return it.created_at;
if (col.type === 'updated') return it.updated_at;
return cellText(board, col, it).toLowerCase();
};
const dir = f.sort.dir === 'desc' ? -1 : 1;
out = [...out].sort((a, b) => { const x = key(a); const y = key(b); return (x > y ? 1 : x < y ? -1 : 0) * dir; });
} else out = [...out].sort((a, b) => a.position - b.position);
return { items: out, subs, matches: anyFilter ? matches : null };
}
export function BoardPage({ boardId, viewId, itemId }) {
const s = useStore();
const mobile = useMobile();
const data = s.boardData[boardId];
useEffect(() => { loadBoard(boardId).catch(() => {}); }, [boardId]);
if (!data) return html`<div class="page-loading"><${Spinner} /></div>`;
if (data.missing) return html`<${Empty} title="This board doesn't exist anymore"><button class="btn primary" onClick=${() => navigate('/')}>Go home</button><//>`;
const view = data.views.find((v) => v.id === viewId) || data.views[0];
const f = getFilter(boardId);
const filtered = filterItems(data, f);
const hidden = view?.settings?.hidden || [];
const ViewComp = { table: TableView, kanban: KanbanView, calendar: CalendarView, timeline: TimelineView, chart: ChartView, files: FilesView }[view?.type] || TableView;
return html`<div class="board-page">
<${BoardHeader} data=${data} view=${view} mobile=${mobile} />
<${Toolbar} data=${data} view=${view} f=${f} mobile=${mobile} />
<div class="board-body view-${view?.type}">
<${ViewComp} key=${view?.id} board=${data} view=${view} items=${filtered.items} subs=${filtered.subs} matches=${filtered.matches} hidden=${hidden} filter=${f} mobile=${mobile} />
</div>
${itemId ? html`<${ItemPanel} key=${itemId} board=${data} itemId=${itemId} viewId=${view?.id} />` : null}
</div>`;
}
function BoardHeader({ data, view, mobile }) {
const b = data.board;
const people = useMemo(() => {
const ids = new Set();
for (const c of data.columns) if (c.type === 'people') for (const it of data.items) for (const u of it.vals[c.id] || []) ids.add(u);
return [...ids];
}, [data]);
const openMenu = (e) => menu(e, [
{ label: 'Rename board', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename board', value: b.name }); if (n) A.renameBoard(b.id, n); } },
{ label: 'Edit description', icon: 'info', onClick: async () => {
const n = await promptDialog({ title: 'Board description', value: b.description || '', confirm: 'Save' });
if (n != null) { mutateBoard(b.id, (d) => { d.board.description = n; }); api.patch(`/api/boards/${b.id}`, { description: n }); }
} },
{ label: 'Rename items…', icon: 'edit', hint: b.item_label, onClick: async () => {
const n = await promptDialog({ title: 'What do you call items on this board?', value: b.item_label, placeholder: 'e.g. Task, Request, Equipment' });
if (n) { mutateBoard(b.id, (d) => { d.board.item_label = n; }); api.patch(`/api/boards/${b.id}`, { item_label: n }); }
} },
{ divider: true },
{ label: 'Move to…', icon: 'move', onClick: () => openModal(MoveBoardModal, { board: b }) },
{ label: 'Duplicate board', icon: 'copy', onClick: () => A.duplicateBoard(b.id) },
{ label: 'Automations', icon: 'bolt', onClick: () => openModal(AutomationsModal, { boardId: b.id }, { class: 'wide' }) },
{ label: 'Activity log', icon: 'activity', onClick: () => openModal(ActivityModal, { boardId: b.id }, { class: 'wide' }) },
{ divider: true },
{ label: 'Export to CSV / Excel', icon: 'download', onClick: () => { location.href = `/api/boards/${b.id}/export.csv`; } },
{ label: 'Import from CSV', icon: 'upload', onClick: () => openModal(ImportCsvModal, { boardId: b.id }) },
{ divider: true },
{ label: 'Delete board', icon: 'trash', danger: true, onClick: () => A.deleteBoard(b.id) },
], { align: 'right' });
return html`<header class="board-header">
<div class="board-title-row">
${mobile ? html`<button class="icon-btn" onClick=${() => update({ sidebarOpen: true })} aria-label="Menu"><${Icon} name="menu" size=${20} /></button>` : null}
<h1 class="board-title"><${InlineEdit} value=${b.name} onSave=${(n) => n && A.renameBoard(b.id, n)} /></h1>
<button class="icon-btn star ${data.favorite ? 'on' : ''}" onClick=${() => A.toggleFavorite(b.id)} title="Favorite"><${Icon} name="star" fill=${data.favorite} /></button>
<div class="spacer" />
${!mobile ? html`
<button class="btn ghost sm" onClick=${() => openModal(ActivityModal, { boardId: b.id }, { class: 'wide' })}><${Icon} name="activity" /> Activity</button>
<button class="btn ghost sm" onClick=${() => openModal(AutomationsModal, { boardId: b.id }, { class: 'wide' })}><${Icon} name="bolt" /> Automate${data.automations.length ? ` / ${data.automations.filter((a) => a.enabled).length}` : ''}</button>
<span class="header-people" title="People on this board"><${AvatarStack} ids=${people} size=${26} max=${4} /></span>` : null}
<button class="icon-btn" onClick=${openMenu} aria-label="Board menu"><${Icon} name="dots" size=${20} /></button>
</div>
${b.description && !mobile ? html`<p class="board-desc">${b.description}</p>` : null}
<${ViewTabs} data=${data} view=${view} />
</header>`;
}
function ViewTabs({ data, view }) {
const b = data.board;
const addView = (e) => menu(e, Object.entries(VIEW_TYPES).map(([type, t]) => ({
label: t.label, icon: t.icon, hint: t.desc,
onClick: async () => {
const r = await serverCall(b.id, api.post(`/api/boards/${b.id}/views`, { type, name: t.label }));
await loadBoard(b.id);
navigate(boardPath(b.id, { viewId: r.id }));
},
})));
const viewMenu = (e, v) => menu(e, [
{ label: 'Rename', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename view', value: v.name }); if (n) { mutateBoard(b.id, () => { v.name = n; }); api.patch(`/api/views/${v.id}`, { name: n }); } } },
{ label: 'Delete', icon: 'trash', danger: true, disabled: data.views.length <= 1, onClick: async () => {
if (!(await confirmDialog({ title: `Delete view "${v.name}"?`, body: 'Only the view is removed — items are untouched.' }))) return;
await serverCall(b.id, api.del(`/api/views/${v.id}`)); await loadBoard(b.id); navigate(boardPath(b.id));
} },
]);
return html`<nav class="view-tabs">
${data.views.map((v) => html`<button key=${v.id} class="view-tab ${v.id === view?.id ? 'active' : ''}"
onClick=${() => navigate(boardPath(b.id, { viewId: v.id }))} onContextMenu=${(e) => viewMenu(e, v)}>
<${Icon} name=${VIEW_TYPES[v.type]?.icon || 'table'} size=${14} /> ${v.name}
${v.id === view?.id ? html`<span class="tab-dots" onClick=${(e) => viewMenu(e, v)}><${Icon} name="dots" size=${14} /></span>` : null}
</button>`)}
<button class="view-tab add" onClick=${addView} title="Add view"><${Icon} name="plus" size=${14} /></button>
</nav>`;
}
function Toolbar({ data, view, f, mobile }) {
const b = data.board;
const [searchOpen, setSearchOpen] = useState(!!f.q);
const firstGroup = [...data.groups].sort((a, z) => a.position - z.position)[0];
const newItem = async () => {
const it = await A.createItem(b.id, { group_id: firstGroup?.id, name: `New ${b.item_label.toLowerCase()}`, top: true });
if (it) navigate(boardPath(b.id, { viewId: view?.id, itemId: it.id }));
};
const hidden = view?.settings?.hidden || [];
const nPeople = f.people.length;
return html`<div class="toolbar">
<div class="split-btn">
<button class="btn primary sm" onClick=${newItem}>New ${b.item_label.toLowerCase()}</button>
<button class="btn primary sm caret" onClick=${(e) => menu(e, [
{ label: 'New group of items', icon: 'group', onClick: () => A.addGroup(b.id) },
{ label: 'Import items from CSV', icon: 'upload', onClick: () => openModal(ImportCsvModal, { boardId: b.id }) },
])}><${Icon} name="chevDown" size=${14} /></button>
</div>
${searchOpen || f.q ? html`<div class="tb-search"><${Icon} name="search" />
<input placeholder="Search this board" value=${f.q} autofocus onInput=${(e) => setFilter(b.id, { q: e.target.value })}
onBlur=${() => { if (!f.q) setSearchOpen(false); }} onKeyDown=${(e) => { if (e.key === 'Escape') { setFilter(b.id, { q: '' }); setSearchOpen(false); } }} />
${f.q ? html`<button class="icon-btn" onClick=${() => setFilter(b.id, { q: '' })}><${Icon} name="x" size=${14} /></button>` : null}</div>`
: html`<button class="tb-btn" onClick=${() => setSearchOpen(true)}><${Icon} name="search" /> ${mobile ? '' : 'Search'}</button>`}
<button class="tb-btn ${nPeople ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, PersonFilter, { boardId: b.id })}>
${nPeople === 1 ? html`<${Avatar} user=${state.usersById[f.people[0]]} size=${20} />` : html`<${Icon} name="person" />`} ${mobile ? '' : 'Person'}${nPeople > 1 ? ` / ${nPeople}` : ''}</button>
<button class="tb-btn ${f.rules.length ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, FilterBuilder, { boardId: b.id }, { keepOnScroll: true })}>
<${Icon} name="filter" /> ${mobile ? '' : 'Filter'}${f.rules.length ? ` / ${f.rules.length}` : ''}</button>
<button class="tb-btn ${f.sort ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, SortBuilder, { boardId: b.id })}>
<${Icon} name="sort" /> ${mobile ? '' : 'Sort'}</button>
${view?.type === 'table' || view?.type === 'kanban' ? html`<button class="tb-btn ${hidden.length ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, HideColumns, { boardId: b.id, view })}>
<${Icon} name="eyeOff" /> ${mobile ? '' : 'Hide'}${hidden.length ? ` / ${hidden.length}` : ''}</button>` : null}
${activeFilterCount(f) || f.sort ? html`<button class="tb-btn subtle" onClick=${() => setFilter(b.id, { q: '', people: [], rules: [], sort: null })}>Clear all</button>` : null}
</div>`;
}
function PersonFilter({ boardId }) {
const s = useStore();
const f = getFilter(boardId);
const data = s.boardData[boardId];
const ids = new Set();
for (const c of data.columns) if (c.type === 'people') for (const it of data.items) for (const u of it.vals[c.id] || []) ids.add(u);
const users = [...ids].map((i) => s.usersById[i]).filter(Boolean);
const toggle = (id) => setFilter(boardId, { people: f.people.includes(id) ? f.people.filter((x) => x !== id) : [...f.people, id] });
return html`<div class="pop-pad person-filter">
<div class="pop-title">Filter by person</div>
<div class="avatar-row">
${users.map((u) => html`<button key=${u.id} class="avatar-toggle ${f.people.includes(u.id) ? 'on' : ''}" onClick=${() => toggle(u.id)} title=${u.name}><${Avatar} user=${u} size=${32} /></button>`)}
${!users.length ? html`<span class="muted">No one is assigned on this board yet.</span>` : null}
</div>
${s.me && ids.has(s.me.id) ? html`<button class="btn ghost sm" onClick=${() => setFilter(boardId, { people: [s.me.id] })}>Just me</button>` : null}
</div>`;
}
const OPS = {
status: [['is', 'is'], ['is_not', 'is not']], dropdown: [['is', 'is'], ['is_not', 'is not'], ['empty', 'is empty']],
people: [['is', 'is'], ['is_not', 'is not'], ['empty', 'is empty']],
date: [['before', 'is before'], ['after', 'is after'], ['empty', 'is empty'], ['not_empty', 'is not empty']],
timeline: [['before', 'starts before'], ['after', 'starts after'], ['empty', 'is empty']],
checkbox: [['not_empty', 'is checked'], ['empty', 'is not checked']],
};
const defaultOps = [['contains', 'contains'], ['empty', 'is empty'], ['not_empty', 'is not empty']];
function FilterBuilder({ boardId }) {
const s = useStore();
const data = s.boardData[boardId];
const f = getFilter(boardId);
const cols = data.columns.filter((c) => !['files', 'item_id', 'updated', 'created'].includes(c.type));
const setRules = (rules) => setFilter(boardId, { rules });
const add = () => { const c = cols[0]; if (c) setRules([...f.rules, { colId: c.id, op: (OPS[c.type] || defaultOps)[0][0], values: [] }]); };
return html`<div class="pop-pad filter-builder">
<div class="pop-title">Advanced filters <span class="muted small">Only you see these</span></div>
${f.rules.map((r, i) => {
const col = data.columns.find((c) => c.id === r.colId);
const ops = OPS[col?.type] || defaultOps;
const set = (patch) => setRules(f.rules.map((x, j) => (j === i ? { ...x, ...patch } : x)));
return html`<div key=${i} class="filter-rule">
<span class="muted small">${i === 0 ? 'Where' : 'And'}</span>
<select class="input sm" value=${r.colId} onChange=${(e) => { const c = data.columns.find((x) => x.id === e.target.value); set({ colId: c.id, op: (OPS[c.type] || defaultOps)[0][0], values: [] }); }}>
${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
</select>
<select class="input sm" value=${r.op} onChange=${(e) => set({ op: e.target.value })}>${ops.map(([v, l]) => html`<option value=${v}>${l}</option>`)}</select>
<${RuleValue} col=${col} rule=${r} set=${set} />
<button class="icon-btn" onClick=${() => setRules(f.rules.filter((_, j) => j !== i))}><${Icon} name="x" /></button>
</div>`;
})}
<div class="row gap"><button class="btn ghost sm" onClick=${add}><${Icon} name="plus" /> New filter</button>
${f.rules.length ? html`<button class="btn ghost sm" onClick=${() => setRules([])}>Clear</button>` : null}</div>
${!f.rules.length ? html`<div class="quick-filters">${data.columns.filter((c) => c.type === 'status').slice(0, 2).map((c) => html`
<div key=${c.id} class="qf-col"><div class="qf-title">${c.title}</div>
${(c.settings.labels || []).map((l) => html`<button key=${l.id} class="qf-opt" onClick=${() => setRules([{ colId: c.id, op: 'is', values: [l.id] }])}>
<i style=${{ background: l.color }} />${l.name}<span class="muted">${data.items.filter((it) => it.vals[c.id] === l.id).length}</span></button>`)}</div>`)}</div>` : null}
</div>`;
}
function RuleValue({ col, rule, set }) {
if (!col || ['empty', 'not_empty'].includes(rule.op)) return html`<span />`;
if (col.type === 'status' || col.type === 'dropdown') {
return html`<select class="input sm" multiple size="3" onChange=${(e) => set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
${(col.settings.labels || []).map((l) => html`<option value=${l.id} selected=${rule.values.includes(l.id)}>${l.name}</option>`)}
${col.type === 'status' ? html`<option value="__empty" selected=${rule.values.includes('__empty')}>(empty)</option>` : null}
</select>`;
}
if (col.type === 'people') {
return html`<select class="input sm" multiple size="3" onChange=${(e) => set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
${state.users.map((u) => html`<option value=${u.id} selected=${rule.values.includes(u.id)}>${u.name}</option>`)}</select>`;
}
if (col.type === 'date' || col.type === 'timeline') {
return html`<input type="date" class="input sm" value=${rule.values[0] || ''} onInput=${(e) => set({ values: [e.target.value] })} />`;
}
return html`<input class="input sm" value=${rule.values[0] || ''} placeholder="Value" onInput=${(e) => set({ values: [e.target.value] })} />`;
}
function SortBuilder({ boardId }) {
const s = useStore();
const data = s.boardData[boardId];
const f = getFilter(boardId);
const sort = f.sort || { colId: '', dir: 'asc' };
return html`<div class="pop-pad sort-builder">
<div class="pop-title">Sort by</div>
<div class="row gap">
<select class="input sm" value=${sort.colId} onChange=${(e) => setFilter(boardId, { sort: e.target.value ? { ...sort, colId: e.target.value } : null })}>
<option value="">Choose column</option>
<option value="__name">${data.board.item_label}</option>
${data.columns.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
</select>
<select class="input sm" value=${sort.dir} disabled=${!f.sort} onChange=${(e) => setFilter(boardId, { sort: { ...sort, dir: e.target.value } })}>
<option value="asc">Ascending</option><option value="desc">Descending</option>
</select>
${f.sort ? html`<button class="icon-btn" onClick=${() => setFilter(boardId, { sort: null })}><${Icon} name="x" /></button>` : null}
</div>
</div>`;
}
function HideColumns({ boardId, view }) {
const s = useStore();
const data = s.boardData[boardId];
const v = data.views.find((x) => x.id === view.id) || view;
const hidden = v.settings?.hidden || [];
const save = (h) => { mutateBoard(boardId, () => { v.settings = { ...v.settings, hidden: h }; }); api.patch(`/api/views/${v.id}`, { settings: { ...v.settings, hidden: h } }); };
return html`<div class="pop-pad hide-cols">
<div class="pop-title">Display columns <span class="muted small">Saved for everyone on this view</span></div>
<label class="check-row"><input type="checkbox" checked=${!hidden.length} onChange=${(e) => save(e.target.checked ? [] : data.columns.map((c) => c.id))} /> All columns</label>
${data.columns.map((c) => html`<label key=${c.id} class="check-row">
<input type="checkbox" checked=${!hidden.includes(c.id)} onChange=${(e) => save(e.target.checked ? hidden.filter((x) => x !== c.id) : [...hidden, c.id])} />
<span class="type-dot" style=${{ background: COLUMN_TYPES[c.type]?.color }}><${Icon} name=${COLUMN_TYPES[c.type]?.icon || 'edit'} size=${11} /></span>${c.title}</label>`)}
</div>`;
}
function MoveBoardModal({ board }) {
const s = useStore();
const [ws, setWs] = useState(board.workspace_id);
const [folder, setFolder] = useState(board.folder_id || '');
const save = async () => {
await api.patch(`/api/boards/${board.id}`, { workspace_id: ws, folder_id: folder || null });
await loadTree(); loadBoard(board.id);
closeModal();
toast('Board moved');
};
return html`<div class="dialog"><${ModalHeader} title="Move board" />
<label class="field-label">Workspace</label>
<select class="input" value=${ws} onChange=${(e) => { setWs(e.target.value); setFolder(''); }}>${s.workspaces.map((w) => html`<option value=${w.id}>${w.name}</option>`)}</select>
<label class="field-label">Folder</label>
<select class="input" value=${folder} onChange=${(e) => setFolder(e.target.value)}>
<option value="">(no folder)</option>
${s.folders.filter((f) => f.workspace_id === ws).map((f) => html`<option value=${f.id}>${f.parent_id ? '— ' : ''}${f.name}</option>`)}
</select>
<div class="modal-actions"><button class="btn primary" onClick=${save}>Move</button></div></div>`;
}
+96
View File
@@ -0,0 +1,96 @@
// Board-level dialogs: activity log, CSV import, column settings.
import { html, useState, useEffect } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, useStore, loadBoard, navigate, boardPath, toast } from './store.js';
import { Icon, Avatar, ModalHeader, closeModal, timeAgo, Spinner, LABEL_COLORS, textOn } from './ui.js';
import { COLUMN_TYPES } from './cells.js';
import { activityText } from './item.js';
import * as A from './actions.js';
export function ActivityModal({ boardId }) {
const [rows, setRows] = useState(null);
const [who, setWho] = useState('');
useEffect(() => { api.get(`/api/boards/${boardId}/activity`).then(setRows); }, [boardId]);
const shown = (rows || []).filter((a) => !who || a.user_id === who);
const people = [...new Set((rows || []).map((a) => a.user_id).filter(Boolean))];
return html`<div class="dialog"><${ModalHeader} title="Activity log" sub=${state.boardData[boardId]?.board.name} />
<div class="row gap wrap">
<button class="chip-btn ${!who ? 'on' : ''}" onClick=${() => setWho('')}>Everyone</button>
${people.map((u) => html`<button key=${u} class="chip-btn ${who === u ? 'on' : ''}" onClick=${() => setWho(u)}><${Avatar} user=${state.usersById[u]} size=${20} /> ${state.usersById[u]?.name}</button>`)}
</div>
<div class="activity scroll-y">
${!rows ? html`<${Spinner} />` : shown.map((a) => html`<div key=${a.id} class="act-row">
<span class="muted small act-time">${timeAgo(a.created_at)}</span>
<${Avatar} user=${state.usersById[a.user_id] || (a.user_id ? null : { name: 'Automation', email: 'auto' })} size=${24} />
${a.item_name ? html`<button class="link act-item" onClick=${() => { closeModal(); navigate(boardPath(boardId, { itemId: a.item_id })); }}>${a.item_name}</button>` : html`<span class="act-who">${state.usersById[a.user_id]?.name || 'Automation'}</span>`}
<span class="act-what">${a.action === 'board_created' ? 'created the board' : a.action === 'item_deleted' ? html`deleted <b>${a.data?.name}</b>` : a.action.startsWith('column_') || a.action === 'group_deleted' ? html`${a.action.replace('_', ' ')} <b>${a.data?.title}</b>` : activityText(a)}</span>
</div>`)}
${rows && !shown.length ? html`<p class="muted pad">Nothing yet.</p>` : null}
</div></div>`;
}
export function ImportCsvModal({ boardId }) {
const [busy, setBusy] = useState(false);
const pick = async (file) => {
if (!file) return;
setBusy(true);
try {
const text = await file.text();
const r = await api.post(`/api/boards/${boardId}/import.csv`, text, { contentType: 'text/csv' });
toast(`Imported ${r.created} items`); closeModal(); loadBoard(boardId);
} catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
return html`<div class="dialog"><${ModalHeader} title="Import items from CSV" />
<p class="muted">The first row must be column headers. A <b>Name</b> (or first) column becomes the item name, a <b>Group</b> column picks the group,
and other headers fill columns with the same title — new columns are created as text. Export from Excel or Google Sheets with "Save as CSV".</p>
<label class="dropzone"><${Icon} name="upload" size=${24} /><span>${busy ? 'Importing…' : 'Choose a .csv file'}</span>
<input type="file" accept=".csv,text/csv" hidden onChange=${(e) => pick(e.target.files[0])} /></label>
</div>`;
}
export function ColumnSettingsModal({ boardId, colId }) {
const s = useStore();
const col = s.boardData[boardId]?.columns.find((c) => c.id === colId);
const [title, setTitle] = useState(col?.title || '');
const [type, setType] = useState(col?.type);
const [settings, setSettings] = useState(() => JSON.parse(JSON.stringify(col?.settings || {})));
if (!col) return null;
const labels = settings.labels || [];
const setLabels = (l) => setSettings({ ...settings, labels: l });
const save = async () => {
const patch = {};
if (title.trim() && title !== col.title) patch.title = title.trim();
if (type !== col.type) patch.type = type;
else patch.settings = settings;
await A.patchColumn(boardId, colId, patch);
if (patch.type) await loadBoard(boardId);
closeModal();
};
return html`<div class="dialog"><${ModalHeader} title="Column settings" />
<label class="field-label">Title</label><input class="input" value=${title} onInput=${(e) => setTitle(e.target.value)} />
<label class="field-label">Type</label>
<select class="input" value=${type} onChange=${(e) => setType(e.target.value)}>
${Object.entries(COLUMN_TYPES).filter(([k]) => k !== 'priority').map(([k, t]) => html`<option value=${k}>${t.label}</option>`)}
</select>
${type !== col.type ? html`<p class="warn-note">Changing the type keeps values that convert to text cleanly and clears the rest.</p>` : null}
${type === col.type && (col.type === 'status' || col.type === 'dropdown') ? html`
<label class="field-label">Labels</label>
<div class="label-editor">
${labels.map((l, i) => html`<div key=${l.id} class="label-row">
<input type="color" class="swatch-input" value=${l.color} onInput=${(e) => setLabels(labels.map((x, j) => (j === i ? { ...x, color: e.target.value } : x)))} />
<input class="input sm" value=${l.name} onInput=${(e) => setLabels(labels.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />
${col.type === 'status' ? html`<label class="small muted done-toggle"><input type="checkbox" checked=${!!l.done} onChange=${(e) => setLabels(labels.map((x, j) => (j === i ? { ...x, done: e.target.checked } : x)))} /> Done</label>` : null}
<button class="icon-btn" disabled=${i === 0} onClick=${() => { const n = [...labels]; [n[i - 1], n[i]] = [n[i], n[i - 1]]; setLabels(n); }}><${Icon} name="chevUp" /></button>
<button class="icon-btn" onClick=${() => setLabels(labels.filter((_, j) => j !== i))}><${Icon} name="trash" /></button>
</div>`)}
<button class="btn ghost sm" onClick=${() => setLabels([...labels, { id: Math.random().toString(36).slice(2, 9), name: 'New label', color: LABEL_COLORS[labels.length % LABEL_COLORS.length] }])}><${Icon} name="plus" /> Add label</button>
</div>
<div class="label-preview">${labels.map((l) => html`<span key=${l.id} style=${{ background: l.color, color: textOn(l.color) }}>${l.name}</span>`)}</div>` : null}
${type === col.type && col.type === 'number' ? html`<label class="field-label">Unit</label>
<input class="input" value=${settings.unit || ''} placeholder="$, hrs, %" onInput=${(e) => setSettings({ ...settings, unit: e.target.value })} />` : null}
${type === col.type && col.type === 'rating' ? html`<label class="field-label">Maximum</label>
<select class="input" value=${settings.max || 5} onChange=${(e) => setSettings({ ...settings, max: Number(e.target.value) })}>${[3, 4, 5, 6, 7, 8, 9, 10].map((n) => html`<option value=${n}>${n}</option>`)}</select>` : null}
<div class="modal-actions"><button class="btn ghost" onClick=${closeModal}>Cancel</button><button class="btn primary" onClick=${save}>Save</button></div>
</div>`;
}
+86
View File
@@ -0,0 +1,86 @@
// Calendar: month grid of items by a date (or timeline) column; drag to reschedule.
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { navigate, boardPath, mutateBoard } from './store.js';
import { Icon, ymd, parseDate, todayStr, textOn, Empty, useMobile } from './ui.js';
import { labelOf } from './cells.js';
import * as A from './actions.js';
export function CalendarView({ board, view, items, subs }) {
const boardId = board.board.id;
const mobile = useMobile();
const dateCols = board.columns.filter((c) => c.type === 'date' || c.type === 'timeline');
const col = dateCols.find((c) => c.id === view.settings?.column) || dateCols[0];
const status = board.columns.find((c) => c.type === 'status');
const [month, setMonth] = useState(() => { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); });
const [drag, setDrag] = useState(null);
if (!col) return html`<${Empty} icon="calendar" title="Calendar needs a date column"><button class="btn primary" onClick=${() => A.addColumn(boardId, 'date')}>Add a date column</button><//>`;
const all = [...items, ...items.flatMap((i) => subs[i.id] || [])];
const byDay = {};
for (const it of all) {
const v = it.vals[col.id];
if (!v) continue;
if (col.type === 'date') (byDay[String(v).slice(0, 10)] ||= []).push(it);
else {
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
for (let d = new Date(a); d <= b && (d - a) < 400 * 86400000; d.setDate(d.getDate() + 1)) (byDay[ymd(d)] ||= []).push(it);
}
}
const start = new Date(month); start.setDate(1 - start.getDay());
const days = Array.from({ length: 42 }, (_, i) => { const d = new Date(start); d.setDate(start.getDate() + i); return d; });
const setCol = (id) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, column: id }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, column: id } }); };
const drop = (day) => {
if (!drag) return;
const v = drag.vals[col.id];
if (col.type === 'date') A.setValue(boardId, drag.id, col.id, String(v || '').includes('T') ? `${day}T${String(v).split('T')[1]}` : day);
else {
const len = v?.from ? (parseDate(v.to) - parseDate(v.from)) : 0;
const to = new Date(parseDate(day).getTime() + len);
A.setValue(boardId, drag.id, col.id, { from: day, to: ymd(to) });
}
setDrag(null);
};
const chip = (it) => {
const l = status && labelOf(status, it.vals[status.id]);
return html`<button key=${it.id} class="cal-item" draggable="true" onDragStart=${() => setDrag(it)}
style=${{ background: l?.color || 'var(--primary)', color: textOn(l?.color || '#0073ea') }}
onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>${it.name}</button>`;
};
if (mobile) {
// Agenda list on phones.
const keys = Object.keys(byDay).filter((k) => k.slice(0, 7) === ymd(month).slice(0, 7)).sort();
return html`<div class="calendar mobile">
<${CalHead} month=${month} setMonth=${setMonth} dateCols=${dateCols} col=${col} setCol=${setCol} />
${keys.map((k) => html`<div key=${k} class="agenda-day"><div class="agenda-date ${k === todayStr() ? 'today' : ''}">${parseDate(k).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}</div>
<div class="agenda-items">${byDay[k].map(chip)}</div></div>`)}
${!keys.length ? html`<p class="muted pad">Nothing scheduled this month.</p>` : null}
</div>`;
}
return html`<div class="calendar">
<${CalHead} month=${month} setMonth=${setMonth} dateCols=${dateCols} col=${col} setCol=${setCol} />
<div class="cal-grid">
${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => html`<div key=${d} class="cal-dow">${d}</div>`)}
${days.map((d) => {
const k = ymd(d); const list = byDay[k] || [];
return html`<div key=${k} class="cal-day ${d.getMonth() !== month.getMonth() ? 'out' : ''} ${k === todayStr() ? 'today' : ''}"
onDragOver=${(e) => e.preventDefault()} onDrop=${() => drop(k)}
onDblClick=${async () => { const it = await A.createItem(boardId, { group_id: board.groups[0]?.id, name: 'New item', vals: { [col.id]: col.type === 'date' ? k : { from: k, to: k } } }); if (it) navigate(boardPath(boardId, { viewId: view.id, itemId: it.id })); }}>
<span class="cal-num">${d.getDate()}</span>
<div class="cal-items">${list.slice(0, 4).map(chip)}${list.length > 4 ? html`<span class="muted small">+${list.length - 4} more</span>` : null}</div>
</div>`;
})}
</div>
<p class="muted small pad-x">Drag items to reschedule · double-click a day to add an item</p>
</div>`;
}
function CalHead({ month, setMonth, dateCols, col, setCol }) {
return html`<div class="cal-head">
<button class="btn ghost sm" onClick=${() => { const d = new Date(); setMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }}>Today</button>
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><${Icon} name="chevLeft" /></button>
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><${Icon} name="chevRight" /></button>
<h3>${month.toLocaleString([], { month: 'long', year: 'numeric' })}</h3>
<span class="spacer" />
${dateCols.length > 1 ? html`<select class="input sm" value=${col.id} onChange=${(e) => setCol(e.target.value)}>${dateCols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>` : null}
</div>`;
}
+388
View File
@@ -0,0 +1,388 @@
// Column types: metadata, cell display/editing, pickers and group-footer summaries.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { state, toast } from './store.js';
import { api } from './api.js';
import { setValue, patchColumn } from './actions.js';
import {
Icon, Avatar, AvatarStack, openPopover, closePopover, InlineEdit, fmtDate, fmtRange, parseDate, ymd, todayStr,
textOn, LABEL_COLORS, fileUrl, isImage, fmtSize, timeAgo, ColorPicker,
} from './ui.js';
export const COLUMN_TYPES = {
status: { label: 'Status', icon: 'check', color: '#00c875' },
priority: { label: 'Priority', icon: 'bolt', color: '#401694' },
text: { label: 'Text', icon: 'edit', color: '#fdab3d' },
people: { label: 'People', icon: 'person', color: '#579bfc' },
date: { label: 'Date', icon: 'calendar', color: '#a25ddc' },
timeline: { label: 'Timeline', icon: 'timeline', color: '#9d50dd' },
number: { label: 'Numbers', icon: 'chart', color: '#ffcb00' },
long_text: { label: 'Long text', icon: 'message', color: '#ff642e' },
dropdown: { label: 'Dropdown', icon: 'chevDown', color: '#00a9ff' },
tags: { label: 'Tags', icon: 'pin', color: '#00c875' },
checkbox: { label: 'Checkbox', icon: 'check', color: '#ff7575' },
files: { label: 'Files', icon: 'paperclip', color: '#f65f7c' },
link: { label: 'Link', icon: 'link', color: '#66ccff' },
email: { label: 'Email', icon: 'message', color: '#0086c0' },
phone: { label: 'Phone', icon: 'message', color: '#037f4c' },
rating: { label: 'Rating', icon: 'star', color: '#ffcb00' },
created: { label: 'Creation log', icon: 'activity', color: '#7f5347' },
updated: { label: 'Last updated', icon: 'refresh', color: '#7f5347' },
item_id: { label: 'Item ID', icon: 'info', color: '#676879' },
};
export const DEFAULT_WIDTH = { status: 140, people: 110, date: 120, timeline: 170, number: 110, checkbox: 90, rating: 130, files: 120, text: 170, long_text: 200, dropdown: 170, tags: 170, link: 170, email: 180, phone: 140, created: 160, updated: 140, item_id: 110 };
export const colWidth = (c) => c.width || DEFAULT_WIDTH[c.type] || 150;
export function labelOf(col, id) { return (col.settings?.labels || []).find((l) => l.id === id); }
export function isDoneLabel(l) { return !!l && (l.done || /^done$/i.test(l.name)); }
// Is the item "done"? — first status column with a done label.
export function itemDone(board, item) {
for (const c of board.columns) if (c.type === 'status' && isDoneLabel(labelOf(c, item.vals[c.id]))) return true;
return false;
}
// Plain-text value, for search, sort and filters.
export function cellText(board, col, item) {
const v = item.vals[col.id];
switch (col.type) {
case 'status': return labelOf(col, v)?.name || '';
case 'dropdown': return (v || []).map((x) => labelOf(col, x)?.name).filter(Boolean).join(', ');
case 'people': return (v || []).map((u) => state.usersById[u]?.name).filter(Boolean).join(', ');
case 'timeline': return v?.from ? `${v.from} ${v.to}` : '';
case 'link': return v ? (v.text || v.url || '') : '';
case 'tags': return (v || []).join(', ');
case 'checkbox': return v ? 'checked' : '';
case 'files': return (board.files || []).filter((f) => (v || []).includes(f.id)).map((f) => f.name).join(', ');
case 'created': return state.usersById[item.created_by]?.name || '';
default: return v == null ? '' : String(v);
}
}
// ─── Cell ────────────────────────────────────────────────
export function Cell({ board, col, item, compact }) {
const v = item.vals[col.id];
const boardId = board.board.id;
const set = (val) => setValue(boardId, item.id, col.id, val);
const open = (e, Comp, extra = {}, opts = {}) => { e.stopPropagation(); openPopover(e.currentTarget, Comp, { board, col, item, set, ...extra }, opts); };
switch (col.type) {
case 'status': {
const l = labelOf(col, v);
return html`<button class="cell-status" style=${{ background: l?.color || 'var(--status-empty)', color: l ? textOn(l.color) : 'transparent' }}
onClick=${(e) => open(e, StatusPicker, {}, { align: 'center' })}>
<span class="fold" />${l?.name || '-'}</button>`;
}
case 'people':
return html`<button class="cell-people" onClick=${(e) => open(e, PeoplePicker, {}, { align: 'center' })}>
<${AvatarStack} ids=${v || []} size=${compact ? 24 : 28} /></button>`;
case 'date': {
const d = parseDate(v);
const overdue = d && ymd(d) < todayStr() && !itemDone(board, item);
const today = d && ymd(d) === todayStr();
return html`<button class="cell-date ${overdue ? 'overdue' : ''}" onClick=${(e) => open(e, DatePicker, {}, { align: 'center' })}>
${d ? html`${overdue ? html`<span class="warn" title="Overdue">!</span>` : today ? html`<span class="today-dot" />` : null}${fmtDate(v)}` : html`<span class="ghost-icon"><${Icon} name="calendar" /></span>`}
</button>`;
}
case 'timeline': {
const g = board.groups.find((x) => x.id === (item.group_id || board.items.find((i) => i.id === item.parent_id)?.group_id));
const pct = v?.from ? timelineProgress(v) : 0;
return html`<button class="cell-timeline" onClick=${(e) => open(e, DatePicker, { range: true }, { align: 'center' })}>
${v?.from ? html`<span class="tl-bar" style=${{ background: `linear-gradient(90deg, ${g?.color || '#579bfc'} ${pct}%, var(--tl-rest) ${pct}%)` }}><span>${fmtRange(v)}</span></span>`
: html`<span class="tl-bar empty"><span>-</span></span>`}
</button>`;
}
case 'number':
return html`<div class="cell-number"><${InlineEdit} value=${v == null ? '' : String(v)} placeholder=""
onSave=${(s) => { const n = s === '' ? null : Number(s.replace(/[^0-9.\-]/g, '')); if (s !== '' && !Number.isFinite(n)) return toast('Numbers only', 'error'); set(n); }} />
${v != null && col.settings?.unit ? html`<span class="unit">${col.settings.unit}</span>` : null}</div>`;
case 'checkbox':
return html`<label class="cell-check" onClick=${(e) => e.stopPropagation()}><input type="checkbox" checked=${!!v} onChange=${(e) => set(e.target.checked || null)} /><span /></label>`;
case 'rating': {
const max = col.settings?.max || 5;
return html`<div class="cell-rating">${Array.from({ length: max }, (_, i) => html`
<button key=${i} class=${i < (v || 0) ? 'on' : ''} onClick=${(e) => { e.stopPropagation(); set(v === i + 1 ? null : i + 1); }}><${Icon} name="star" fill=${i < (v || 0)} size=${15} /></button>`)}</div>`;
}
case 'long_text':
return html`<button class="cell-text long" title=${v || ''} onClick=${(e) => open(e, LongTextEditor, {}, { keepOnScroll: true })}>${v || ''}</button>`;
case 'dropdown':
return html`<button class="cell-chips" onClick=${(e) => open(e, LabelsPicker, {}, { matchWidth: true })}>
${(v || []).map((x) => labelOf(col, x)).filter(Boolean).map((l) => html`<span key=${l.id} class="chip">${l.name}</span>`)}</button>`;
case 'tags':
return html`<button class="cell-chips" onClick=${(e) => open(e, TagsEditor, {}, { matchWidth: true })}>
${(v || []).map((t) => html`<span key=${t} class="chip tag">#${t}</span>`)}</button>`;
case 'link':
return html`<div class="cell-link">
${v?.url ? html`<a href=${v.url} target="_blank" rel="noopener" onClick=${(e) => e.stopPropagation()}>${v.text || v.url}</a>` : null}
<button class="cell-edit-btn" onClick=${(e) => open(e, LinkEditor)}><${Icon} name=${v?.url ? 'edit' : 'link'} size=${13} /></button></div>`;
case 'email':
return html`<div class="cell-link">${v ? html`<a href=${'mailto:' + v} onClick=${(e) => e.stopPropagation()}>${v}</a>` : null}
<button class="cell-edit-btn" onClick=${(e) => open(e, SimpleEditor, { kind: 'email' })}><${Icon} name="edit" size=${13} /></button></div>`;
case 'phone':
return html`<div class="cell-link">${v ? html`<a href=${'tel:' + v} onClick=${(e) => e.stopPropagation()}>${v}</a>` : null}
<button class="cell-edit-btn" onClick=${(e) => open(e, SimpleEditor, { kind: 'tel' })}><${Icon} name="edit" size=${13} /></button></div>`;
case 'files': {
const files = (board.files || []).filter((f) => (v || []).includes(f.id));
return html`<button class="cell-files" onClick=${(e) => open(e, FilesEditor, {}, { keepOnScroll: true })}>
${files.slice(0, 3).map((f) => isImage(f) ? html`<img key=${f.id} src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span key=${f.id} class="file-ext">${ext(f.name)}</span>`)}
${files.length > 3 ? html`<span class="file-more">+${files.length - 3}</span>` : null}
${!files.length ? html`<span class="ghost-icon"><${Icon} name="paperclip" /></span>` : null}
</button>`;
}
case 'created':
return html`<div class="cell-meta"><${Avatar} user=${state.usersById[item.created_by]} size=${22} /><span>${fmtDate(ymd(new Date(item.created_at)))}</span></div>`;
case 'updated':
return html`<div class="cell-meta muted">${timeAgo(item.updated_at)} ago</div>`;
case 'item_id':
return html`<div class="cell-meta muted mono">${item.id}</div>`;
default:
return html`<div class="cell-text"><${InlineEdit} value=${v || ''} onSave=${(s) => set(s || null)} /></div>`;
}
}
const ext = (n) => (n.split('.').pop() || 'file').slice(0, 4).toUpperCase();
function timelineProgress(v) {
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
const now = new Date(); now.setHours(0, 0, 0, 0);
if (now < a) return 0;
if (now >= b) return 100;
return Math.round(((now - a) / (b - a + 86400000)) * 100);
}
// ─── Status picker (with label editing) ──────────────────
export function StatusPicker({ board, col, set, item, onPick }) {
const [editing, setEditing] = useState(false);
const labels = col.settings?.labels || [];
const pick = (id) => { closePopover(); onPick ? onPick(id) : set(id); };
if (editing) return html`<${LabelEditor} board=${board} col=${col} onDone=${() => setEditing(false)} />`;
return html`<div class="status-picker">
<div class="status-grid">
${labels.map((l) => html`<button key=${l.id} class="status-opt" style=${{ background: l.color, color: textOn(l.color) }} onClick=${() => pick(l.id)}>${l.name}</button>`)}
<button class="status-opt blank" onClick=${() => pick(null)}>-</button>
</div>
<div class="menu-divider" />
<button class="menu-item" onClick=${() => setEditing(true)}><${Icon} name="edit" /> Edit labels</button>
</div>`;
}
function LabelEditor({ board, col, onDone }) {
const [labels, setLabels] = useState(() => (col.settings?.labels || []).map((l) => ({ ...l })));
const save = () => { patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: labels.filter((l) => l.name.trim()) } }); onDone(); };
const upd = (i, patch) => setLabels(labels.map((l, j) => (j === i ? { ...l, ...patch } : l)));
return html`<div class="label-editor">
${labels.map((l, i) => html`<div key=${l.id} class="label-row">
<button class="swatch popover-keep" style=${{ background: l.color }} onClick=${(e) => {
const next = LABEL_COLORS[(LABEL_COLORS.indexOf(l.color) + 1) % LABEL_COLORS.length]; upd(i, { color: next });
}} title="Click to change color" />
<input class="input sm" value=${l.name} onInput=${(e) => upd(i, { name: e.target.value })} />
${col.type === 'status' ? html`<button class="icon-btn ${l.done ? 'on' : ''}" title="Counts as done" onClick=${() => upd(i, { done: !l.done })}><${Icon} name="check" /></button>` : null}
<button class="icon-btn" title="Remove" onClick=${() => setLabels(labels.filter((_, j) => j !== i))}><${Icon} name="x" /></button>
</div>`)}
<button class="btn ghost sm" onClick=${() => setLabels([...labels, { id: Math.random().toString(36).slice(2, 9), name: 'New label', color: LABEL_COLORS[labels.length % LABEL_COLORS.length] }])}><${Icon} name="plus" /> New label</button>
<div class="modal-actions"><button class="btn ghost sm" onClick=${onDone}>Cancel</button><button class="btn primary sm" onClick=${save}>Apply</button></div>
</div>`;
}
// ─── Dropdown labels (multi) ─────────────────────────────
function LabelsPicker({ board, col, item, set }) {
const [q, setQ] = useState('');
const sel = item.vals[col.id] || [];
const labels = col.settings?.labels || [];
const shown = labels.filter((l) => l.name.toLowerCase().includes(q.toLowerCase()));
const toggle = (id) => set(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
const create = () => {
const l = { id: Math.random().toString(36).slice(2, 9), name: q.trim(), color: LABEL_COLORS[labels.length % LABEL_COLORS.length] };
patchColumn(board.board.id, col.id, { settings: { ...col.settings, labels: [...labels, l] } });
set([...sel, l.id]); setQ('');
};
return html`<div class="picker">
<input class="input sm" placeholder="Search or add options" value=${q} onInput=${(e) => setQ(e.target.value)} autofocus
onKeyDown=${(e) => { if (e.key === 'Enter' && q.trim()) { const ex = labels.find((l) => l.name.toLowerCase() === q.trim().toLowerCase()); ex ? toggle(ex.id) : create(); } }} />
<div class="picker-list">
${shown.map((l) => html`<button key=${l.id} class="picker-row" onClick=${() => toggle(l.id)}>
<span class="check-box ${sel.includes(l.id) ? 'on' : ''}"><${Icon} name="check" size=${12} /></span><span class="chip">${l.name}</span></button>`)}
${q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) ? html`<button class="picker-row" onClick=${create}><${Icon} name="plus" /> Create "${q.trim()}"</button>` : null}
${!labels.length && !q ? html`<div class="muted pad">Type to add the first option</div>` : null}
</div></div>`;
}
function TagsEditor({ col, item, set }) {
const [q, setQ] = useState('');
const tags = item.vals[col.id] || [];
return html`<div class="picker">
<div class="chips-wrap">${tags.map((t) => html`<span key=${t} class="chip tag">#${t}<button onClick=${() => set(tags.filter((x) => x !== t))}><${Icon} name="x" size=${10} /></button></span>`)}</div>
<input class="input sm" placeholder="Add a tag and press Enter" value=${q} autofocus onInput=${(e) => setQ(e.target.value)}
onKeyDown=${(e) => { if (e.key === 'Enter' && q.trim()) { const t = q.trim().replace(/^#/, ''); if (!tags.includes(t)) set([...tags, t]); setQ(''); } }} />
</div>`;
}
// ─── People ──────────────────────────────────────────────
export function PeoplePicker({ col, item, set, value, onChange, single }) {
const [q, setQ] = useState('');
const sel = value ?? (item?.vals[col.id] || []);
const apply = onChange || set;
const users = state.users.filter((u) => u.active && (u.name + ' ' + u.email).toLowerCase().includes(q.toLowerCase()));
const toggle = (id) => {
if (single) { apply(sel.includes(id) ? [] : [id]); closePopover(); return; }
apply(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
};
const chosen = sel.map((id) => state.usersById[id]).filter(Boolean);
return html`<div class="picker people-picker">
${chosen.length ? html`<div class="chips-wrap">${chosen.map((u) => html`<span key=${u.id} class="chip person"><${Avatar} user=${u} size=${20} />${u.name}
<button onClick=${() => toggle(u.id)} aria-label="Remove"><${Icon} name="x" size=${10} /></button></span>`)}</div>` : null}
<input class="input sm" placeholder="Search names" value=${q} onInput=${(e) => setQ(e.target.value)} autofocus />
<div class="picker-list">
<div class="picker-section">Suggested people</div>
${users.filter((u) => !sel.includes(u.id)).slice(0, 50).map((u) => html`<button key=${u.id} class="picker-row" onClick=${() => toggle(u.id)}>
<${Avatar} user=${u} size=${24} /><span>${u.name}</span><span class="muted small">${u.email}</span></button>`)}
${!users.length ? html`<div class="muted pad">No one matches "${q}"</div>` : null}
</div></div>`;
}
// ─── Dates ───────────────────────────────────────────────
export function DatePicker({ col, item, set, range, value: v0, onChange }) {
const value = v0 !== undefined ? v0 : item?.vals[col.id];
const apply = onChange || set;
const init = range ? parseDate(value?.from) : parseDate(value);
const [month, setMonth] = useState(() => { const d = init || new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); });
const [from, setFrom] = useState(range ? value?.from || null : null);
const [time, setTime] = useState(!range && String(value || '').includes('T') ? String(value).split('T')[1] : '');
const [showTime, setShowTime] = useState(!!time);
const sel = range ? null : (value ? String(value).slice(0, 10) : null);
const days = [];
const start = new Date(month); start.setDate(1 - start.getDay());
for (let i = 0; i < 42; i++) { const d = new Date(start); d.setDate(start.getDate() + i); days.push(d); }
const pending = useRef(false);
const click = (d) => {
const s = ymd(d);
if (!range) { apply(showTime && time ? `${s}T${time}` : s); closePopover(); return; }
if (!pending.current) { setFrom(s); pending.current = true; apply({ from: s, to: s }); return; }
const [a, b] = s < from ? [s, from] : [from, s];
pending.current = false; apply({ from: a, to: b }); closePopover();
};
const inRange = (s) => range && value?.from && s >= value.from && s <= (value.to || value.from);
return html`<div class="datepicker">
<div class="dp-head">
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}><${Icon} name="chevLeft" /></button>
<strong>${month.toLocaleString([], { month: 'long', year: 'numeric' })}</strong>
<button class="icon-btn" onClick=${() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}><${Icon} name="chevRight" /></button>
</div>
<div class="dp-grid">
${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => html`<span key=${'h' + i} class="dp-dow">${d}</span>`)}
${days.map((d) => { const s = ymd(d); return html`<button key=${s} class="dp-day ${d.getMonth() !== month.getMonth() ? 'out' : ''} ${s === todayStr() ? 'today' : ''} ${s === sel || (range && (s === value?.from || s === value?.to)) ? 'sel' : ''} ${inRange(s) ? 'inrange' : ''}" onClick=${() => click(d)}>${d.getDate()}</button>`; })}
</div>
${range ? html`<div class="muted small pad-x">${pending.current ? 'Now pick the end date' : 'Pick a start date, then an end date'}</div>` : null}
<div class="dp-foot">
<button class="btn ghost sm" onClick=${() => { click(new Date()); }}>Today</button>
${!range ? html`<button class="btn ghost sm" onClick=${() => setShowTime(!showTime)}>${showTime ? 'No time' : 'Add time'}</button>` : null}
${value ? html`<button class="btn ghost sm" onClick=${() => { apply(null); closePopover(); }}>Clear</button>` : null}
</div>
${showTime && !range ? html`<div class="dp-time"><input type="time" class="input sm" value=${time} onInput=${(e) => setTime(e.target.value)} />
<button class="btn primary sm" disabled=${!sel} onClick=${() => { apply(time ? `${sel}T${time}` : sel); closePopover(); }}>Set</button></div>` : null}
</div>`;
}
// ─── Text-ish editors ────────────────────────────────────
function LongTextEditor({ col, item, set }) {
const [v, setV] = useState(item.vals[col.id] || '');
const save = () => { if (v !== (item.vals[col.id] || '')) set(v.trim() || null); closePopover(); };
return html`<div class="longtext-editor">
<textarea class="input" rows="8" value=${v} onInput=${(e) => setV(e.target.value)} autofocus
onKeyDown=${(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save(); }} />
<div class="modal-actions"><span class="muted small">⌘/Ctrl + Enter to save</span><button class="btn primary sm" onClick=${save}>Save</button></div>
</div>`;
}
function LinkEditor({ col, item, set }) {
const cur = item.vals[col.id] || {};
const [url, setUrl] = useState(cur.url || '');
const [text, setText] = useState(cur.text || '');
const save = (e) => { e.preventDefault(); let u = url.trim(); if (u && !/^[a-z]+:/i.test(u)) u = 'https://' + u; set(u ? { url: u, text: text.trim() } : null); closePopover(); };
return html`<form class="form-pop" onSubmit=${save}>
<label class="field-label">Web address</label><input class="input sm" value=${url} onInput=${(e) => setUrl(e.target.value)} placeholder="www.example.com" autofocus />
<label class="field-label">Text to display</label><input class="input sm" value=${text} onInput=${(e) => setText(e.target.value)} placeholder="Optional" />
<div class="modal-actions"><button class="btn primary sm">Save</button></div></form>`;
}
function SimpleEditor({ col, item, set, kind }) {
const [v, setV] = useState(item.vals[col.id] || '');
const save = (e) => { e.preventDefault(); set(v.trim() || null); closePopover(); };
return html`<form class="form-pop" onSubmit=${save}>
<input class="input sm" type=${kind} value=${v} onInput=${(e) => setV(e.target.value)} autofocus />
<div class="modal-actions"><button class="btn primary sm">Save</button></div></form>`;
}
// ─── Files ───────────────────────────────────────────────
export function FilesEditor({ board, col, item }) {
const ids = item.vals[col.id] || [];
const files = (board.files || []).filter((f) => ids.includes(f.id));
const [busy, setBusy] = useState(false);
const input = useRef();
const upload = async (list) => {
if (!list?.length) return;
setBusy(true);
const fd = new FormData();
for (const f of list) fd.append('files', f);
fd.append('column_id', col.id);
try { await api.post(`/api/items/${item.id}/files`, fd); const { loadBoard } = await import('./store.js'); await loadBoard(board.board.id); }
catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
const remove = async (f) => {
await api.del(`/api/files/${f.id}`);
const { loadBoard } = await import('./store.js'); loadBoard(board.board.id);
};
return html`<div class="files-pop" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}>
<div class="files-list">
${files.map((f) => html`<div key=${f.id} class="file-row">
${isImage(f) ? html`<img src=${fileUrl(f)} alt="" />` : html`<span class="file-ext">${ext(f.name)}</span>`}
<a href=${fileUrl(f)} target="_blank" rel="noopener">${f.name}</a><span class="muted small">${fmtSize(f.size)}</span>
<a class="icon-btn" href=${fileUrl(f, true)} title="Download"><${Icon} name="download" /></a>
<button class="icon-btn" title="Remove" onClick=${() => remove(f)}><${Icon} name="trash" /></button></div>`)}
${!files.length ? html`<div class="muted pad">Drop files here</div>` : null}
</div>
<input type="file" multiple hidden ref=${input} onChange=${(e) => upload(e.target.files)} />
<button class="btn primary sm" disabled=${busy} onClick=${() => input.current.click()}><${Icon} name="upload" /> ${busy ? 'Uploading…' : 'Add files'}</button>
</div>`;
}
// ─── Group footer summaries ──────────────────────────────
export function Summary({ board, col, items }) {
if (col.type === 'status' || col.type === 'dropdown') {
const counts = {};
let total = 0;
for (const it of items) {
const vs = col.type === 'status' ? [it.vals[col.id] || '_'] : (it.vals[col.id]?.length ? it.vals[col.id] : ['_']);
for (const v of vs) { counts[v] = (counts[v] || 0) + 1; total++; }
}
if (!total) return html`<div class="summary" />`;
const segs = Object.entries(counts).map(([id, n]) => ({ l: labelOf(col, id), n }))
.sort((a, b) => (a.l ? 0 : 1) - (b.l ? 0 : 1));
return html`<div class="summary"><div class="battery" title=${segs.map((s) => `${s.l?.name || 'Empty'}: ${s.n}`).join('\n')}>
${segs.map((s, i) => html`<span key=${i} style=${{ flex: s.n, background: s.l?.color || 'var(--status-empty)' }} />`)}</div></div>`;
}
if (col.type === 'number') {
const nums = items.map((i) => i.vals[col.id]).filter((x) => typeof x === 'number');
const sum = nums.reduce((a, b) => a + b, 0);
return html`<div class="summary num">${nums.length ? html`<strong>${+sum.toFixed(2)}${col.settings?.unit ? ' ' + col.settings.unit : ''}</strong><span class="muted small">sum</span>` : null}</div>`;
}
if (col.type === 'date' || col.type === 'timeline') {
const ds = items.flatMap((i) => { const v = i.vals[col.id]; return !v ? [] : col.type === 'date' ? [String(v).slice(0, 10)] : [v.from, v.to]; }).filter(Boolean).sort();
if (!ds.length) return html`<div class="summary" />`;
return html`<div class="summary"><span class="tl-bar summary-range"><span>${fmtRange({ from: ds[0], to: ds[ds.length - 1] })}</span></span></div>`;
}
if (col.type === 'people') {
const ids = [...new Set(items.flatMap((i) => i.vals[col.id] || []))];
return html`<div class="summary">${ids.length ? html`<${AvatarStack} ids=${ids} size=${22} />` : null}</div>`;
}
if (col.type === 'checkbox') {
const n = items.filter((i) => i.vals[col.id]).length;
return html`<div class="summary muted small">${items.length ? `${n}/${items.length}` : ''}</div>`;
}
if (col.type === 'files') {
const n = items.reduce((a, i) => a + (i.vals[col.id]?.length || 0), 0);
return html`<div class="summary muted small">${n ? `${n} files` : ''}</div>`;
}
return html`<div class="summary" />`;
}
export { ColorPicker };
+81
View File
@@ -0,0 +1,81 @@
// Chart view (board dashboard) and Files gallery view.
import { html } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, navigate, boardPath, mutateBoard } from './store.js';
import { Icon, fileUrl, isImage, fmtSize, timeAgo, Empty, todayStr, Avatar } from './ui.js';
import { labelOf, itemDone } from './cells.js';
export function ChartView({ board, view, items, subs }) {
const boardId = board.board.id;
const cols = ['status', 'people', 'dropdown'].flatMap((t) => board.columns.filter((c) => c.type === t));
const by = view.settings?.by || cols[0]?.id || '__group';
const kind = view.settings?.kind || 'bar';
const setting = (patch) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, ...patch }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, ...patch } }); };
const buckets = new Map();
const add = (key, label, color, n = 1) => { const b = buckets.get(key) || { label, color, n: 0 }; b.n += n; buckets.set(key, b); };
const col = board.columns.find((c) => c.id === by);
for (const it of items) {
if (by === '__group') { const g = board.groups.find((x) => x.id === it.group_id); add(g?.id, g?.title || '—', g?.color); continue; }
const v = it.vals[by];
if (col.type === 'status') { const l = labelOf(col, v); add(l?.id || '_', l?.name || 'Empty', l?.color || '#c4c4c4'); }
else if (col.type === 'dropdown') { if (!v?.length) add('_', 'Empty', '#c4c4c4'); for (const x of v || []) { const l = labelOf(col, x); if (l) add(l.id, l.name, l.color); } }
else if (col.type === 'people') { if (!v?.length) add('_', 'Unassigned', '#c4c4c4'); for (const u of v || []) add(u, state.usersById[u]?.name || '?', null); }
}
const data = [...buckets.values()].sort((a, b) => b.n - a.n);
const maxN = Math.max(1, ...data.map((d) => d.n));
const total = items.length;
const done = items.filter((i) => itemDone(board, i)).length;
const dateCol = board.columns.find((c) => c.type === 'date');
const overdue = dateCol ? items.filter((i) => i.vals[dateCol.id] && String(i.vals[dateCol.id]).slice(0, 10) < todayStr() && !itemDone(board, i)).length : 0;
const palette = ['#579bfc', '#a25ddc', '#00c875', '#fdab3d', '#e2445c', '#66ccff', '#ff642e', '#037f4c', '#ff158a', '#784bd1'];
let acc = 0;
const arcs = data.map((d, i) => {
const a0 = (acc / total) * Math.PI * 2; acc += d.n; const a1 = (acc / total) * Math.PI * 2;
const large = a1 - a0 > Math.PI ? 1 : 0;
const p = (a) => `${100 + 80 * Math.sin(a)} ${100 - 80 * Math.cos(a)}`;
const path = data.length === 1 ? 'M100 20 A80 80 0 1 1 99.99 20 Z' : `M100 100 L${p(a0)} A80 80 0 ${large} 1 ${p(a1)} Z`;
return html`<path key=${i} d=${path} fill=${d.color || palette[i % palette.length]}><title>${d.label}: ${d.n}</title></path>`;
});
return html`<div class="chart-view">
<div class="kpis">
<div class="kpi"><span class="kpi-n">${total}</span><span class="muted">${board.board.item_label}s</span></div>
<div class="kpi"><span class="kpi-n" style=${{ color: '#00c875' }}>${total ? Math.round((done / total) * 100) : 0}%</span><span class="muted">Done (${done})</span></div>
<div class="kpi"><span class="kpi-n" style=${{ color: overdue ? '#e2445c' : undefined }}>${overdue}</span><span class="muted">Overdue</span></div>
<div class="kpi"><span class="kpi-n">${Object.keys(subs).reduce((a, k) => a + subs[k].length, 0)}</span><span class="muted">Subitems</span></div>
</div>
<div class="chart-card">
<div class="view-options">
<span class="muted small">Group by</span>
<select class="input sm" value=${by} onChange=${(e) => setting({ by: e.target.value })}>
<option value="__group">Group</option>${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
</select>
<div class="seg"><button class=${kind === 'bar' ? 'on' : ''} onClick=${() => setting({ kind: 'bar' })}>Bar</button><button class=${kind === 'pie' ? 'on' : ''} onClick=${() => setting({ kind: 'pie' })}>Pie</button></div>
</div>
${!total ? html`<p class="muted pad">No items to chart.</p>` : kind === 'bar' ? html`<div class="bars">
${data.map((d, i) => html`<div key=${i} class="bar-row">
<span class="bar-label">${d.label}</span>
<span class="bar-track"><span class="bar" style=${{ width: `${(d.n / maxN) * 100}%`, background: d.color || palette[i % palette.length] }} /></span>
<span class="bar-n">${d.n} <span class="muted small">${Math.round((d.n / total) * 100)}%</span></span>
</div>`)}</div>`
: html`<div class="pie-wrap"><svg viewBox="0 0 200 200" width="240" height="240">${arcs}</svg>
<div class="legend">${data.map((d, i) => html`<div key=${i}><i style=${{ background: d.color || palette[i % palette.length] }} />${d.label}<span class="muted"> ${d.n}</span></div>`)}</div></div>`}
</div>
</div>`;
}
export function FilesView({ board, view }) {
const files = board.files || [];
if (!files.length) return html`<${Empty} icon="paperclip" title="No files on this board yet"><p class="muted">Add a Files column or attach files to items.</p><//>`;
const itemName = Object.fromEntries(board.items.map((i) => [i.id, i.name]));
return html`<div class="file-gallery">
${[...files].sort((a, b) => b.created_at - a.created_at).map((f) => html`<div key=${f.id} class="file-card">
<a href=${fileUrl(f)} target="_blank" rel="noopener" class="file-thumb">${isImage(f) ? html`<img src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span class="file-ext big">${(f.name.split('.').pop() || '').slice(0, 4).toUpperCase()}</span>`}</a>
<div class="file-info"><span class="file-name" title=${f.name}>${f.name}</span>
<button class="link small" onClick=${() => navigate(boardPath(board.board.id, { viewId: view.id, itemId: f.item_id }))}>${itemName[f.item_id] || 'Item'}</button>
<span class="muted small">${fmtSize(f.size)} · ${timeAgo(f.created_at)}</span></div>
</div>`)}
</div>`;
}
+272
View File
@@ -0,0 +1,272 @@
// Item panel: slides in from the right (full screen on phones). Updates, info, files, activity.
import { html, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, useStore, navigate, boardPath, loadBoard, toast } from './store.js';
import { Icon, Avatar, InlineEdit, menu, timeAgo, RichText, fileUrl, isImage, fmtSize, confirmDialog, Spinner, fmtDate, ymd } from './ui.js';
import { Cell, COLUMN_TYPES } from './cells.js';
import * as A from './actions.js';
export function ItemPanel({ board, itemId, viewId }) {
const s = useStore();
const boardId = board.board.id;
const item = board.items.find((i) => i.id === itemId);
const [detail, setDetail] = useState(null);
const [tab, setTab] = useState(window.innerWidth < 769 ? 'info' : 'updates');
const close = () => navigate(boardPath(boardId, { viewId }));
const reload = () => api.get(`/api/items/${itemId}`).then(setDetail).catch((e) => { if (e.status === 404) close(); });
useEffect(() => { reload(); }, [itemId]);
useEffect(() => {
const fn = (e) => { if (e.detail === boardId) reload(); };
window.addEventListener('wb:board-changed', fn);
const key = (e) => { if (e.key === 'Escape' && !s.popover && !s.modal && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName)) close(); };
document.addEventListener('keydown', key);
return () => { window.removeEventListener('wb:board-changed', fn); document.removeEventListener('keydown', key); };
}, [itemId]);
if (!item) {
return html`<aside class="item-panel"><div class="panel-head"><button class="icon-btn" onClick=${close}><${Icon} name="x" /></button></div>
${detail === null ? html`<${Spinner} />` : html`<p class="pad muted">This item was deleted or moved.</p>`}</aside>`;
}
const parent = item.parent_id ? board.items.find((i) => i.id === item.parent_id) : null;
const group = board.groups.find((g) => g.id === (item.group_id || parent?.group_id));
const subs = board.items.filter((i) => i.parent_id === item.id).sort((a, b) => a.position - b.position);
const nUpdates = detail?.updates.filter((u) => !u.parent_id).length ?? board.updateCounts?.[item.id] ?? 0;
const nFiles = detail?.files.length ?? 0;
const itemMenu = (e) => menu(e, [
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateItems(boardId, [item.id]) },
{ label: 'Copy link', icon: 'link', onClick: () => { navigator.clipboard?.writeText(location.origin + boardPath(boardId, { itemId: item.id })); toast('Link copied'); } },
!item.parent_id && { label: 'Move to board', icon: 'board', keepOpen: true, onClick: (ev) => menu(ev, state.boards.filter((b) => b.id !== boardId).map((b) => ({ label: b.name, onClick: () => { A.moveItemToBoard(boardId, item.id, b.id); close(); } }))) },
{ label: 'Archive', icon: 'archive', onClick: () => { A.archiveItems(boardId, [item.id]); close(); } },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteItems(boardId, [item.id]) },
], { align: 'right' });
return html`<div class="panel-backdrop" onClick=${close} />
<aside class="item-panel" role="dialog" aria-label=${item.name}>
<div class="panel-head">
<button class="icon-btn" onClick=${close} aria-label="Close"><${Icon} name="x" size=${20} /></button>
<div class="crumbs muted small">
<span>${board.board.name}</span>
${group ? html`<${Icon} name="chevRight" size=${12} /><span style=${{ color: group.color }}>${group.title}</span>` : null}
${parent ? html`<${Icon} name="chevRight" size=${12} /><button class="link" onClick=${() => navigate(boardPath(boardId, { viewId, itemId: parent.id }))}>${parent.name}</button>` : null}
</div>
<button class="icon-btn" onClick=${itemMenu}><${Icon} name="dots" size=${20} /></button>
</div>
<h2 class="panel-title"><${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} /></h2>
<nav class="panel-tabs">
<button class=${tab === 'updates' ? 'active' : ''} onClick=${() => setTab('updates')}>Updates${nUpdates ? ` / ${nUpdates}` : ''}</button>
<button class=${tab === 'info' ? 'active' : ''} onClick=${() => setTab('info')}>Info${subs.length ? ` / ${subs.length}` : ''}</button>
<button class=${tab === 'files' ? 'active' : ''} onClick=${() => setTab('files')}>Files${nFiles ? ` / ${nFiles}` : ''}</button>
<button class=${tab === 'activity' ? 'active' : ''} onClick=${() => setTab('activity')}>Activity</button>
</nav>
<div class="panel-body">
${!detail ? html`<${Spinner} />`
: tab === 'updates' ? html`<${Updates} item=${item} detail=${detail} reload=${reload} board=${board} />`
: tab === 'info' ? html`<${Info} board=${board} item=${item} subs=${subs} viewId=${viewId} detail=${detail} />`
: tab === 'files' ? html`<${Files} item=${item} detail=${detail} reload=${reload} boardId=${boardId} />`
: html`<${Activity} detail=${detail} />`}
</div>
</aside>`;
}
// ─── Updates ─────────────────────────────────────────────
function Updates({ item, detail, reload, board }) {
const top = detail.updates.filter((u) => !u.parent_id);
const replies = {};
for (const u of detail.updates) if (u.parent_id) (replies[u.parent_id] ||= []).unshift(u);
return html`<div class="updates">
<${Composer} itemId=${item.id} onPosted=${reload} boardId=${board.board.id} />
${top.map((u) => html`<${UpdateCard} key=${u.id} u=${u} replies=${replies[u.id] || []} reload=${reload} itemId=${item.id} boardId=${board.board.id} />`)}
${!top.length ? html`<div class="empty-updates"><${Icon} name="message" size=${40} /><h3>No updates yet</h3>
<p class="muted">Share progress, mention a teammate with @, or attach files.</p></div>` : null}
</div>`;
}
function Composer({ itemId, parentId, onPosted, boardId, compact, autoFocus }) {
const [text, setText] = useState('');
const [files, setFiles] = useState([]);
const [mentions, setMentions] = useState([]);
const [suggest, setSuggest] = useState(null);
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(!compact || autoFocus);
const ta = useRef();
const fileIn = useRef();
const onInput = (e) => {
setText(e.target.value);
const upto = e.target.value.slice(0, e.target.selectionStart);
const m = upto.match(/(^|\s)@([\w .'-]{0,30})$/);
setSuggest(m ? m[2] : null);
};
const pick = (u) => {
const pos = ta.current.selectionStart;
const before = text.slice(0, pos).replace(/@([\w .'-]{0,30})$/, `@${u.name} `);
setText(before + text.slice(pos));
setMentions([...new Set([...mentions, u.id])]);
setSuggest(null);
setTimeout(() => { ta.current.focus(); ta.current.selectionStart = ta.current.selectionEnd = before.length; }, 0);
};
const submit = async () => {
if (!text.trim() && !files.length) return;
setBusy(true);
let body = text;
const used = mentions.filter((id) => text.includes('@' + state.usersById[id]?.name));
for (const id of used) body = body.split('@' + state.usersById[id].name).join(`@[${state.usersById[id].name}](${id})`);
const fd = new FormData();
fd.append('body', body);
fd.append('mentions', JSON.stringify(used));
if (parentId) fd.append('parent_id', parentId);
for (const f of files) fd.append('files', f);
try {
await api.post(`/api/items/${itemId}/updates`, fd);
setText(''); setFiles([]); setMentions([]); if (compact) setOpen(false);
onPosted(); loadBoard(boardId);
} catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
if (!open) return html`<button class="reply-open" onClick=${() => setOpen(true)}>Write a reply…</button>`;
const sugg = suggest !== null ? state.users.filter((u) => u.active && u.name.toLowerCase().includes(suggest.toLowerCase())).slice(0, 6) : [];
return html`<div class="composer ${compact ? 'compact' : ''}"
onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); setFiles([...files, ...e.dataTransfer.files]); }}>
<textarea ref=${ta} rows=${compact ? 2 : 3} placeholder=${parentId ? 'Write a reply… (@ to mention)' : 'Write an update and mention others with @'} value=${text}
onInput=${onInput} autofocus=${autoFocus}
onKeyDown=${(e) => {
if (sugg.length && (e.key === 'Enter' || e.key === 'Tab')) { e.preventDefault(); pick(sugg[0]); return; }
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit();
if (e.key === 'Escape') setSuggest(null);
}}
onPaste=${(e) => { const fs = [...(e.clipboardData?.files || [])]; if (fs.length) { e.preventDefault(); setFiles([...files, ...fs]); } }} />
${sugg.length ? html`<div class="mention-pop">${sugg.map((u) => html`<button key=${u.id} onMouseDown=${(e) => { e.preventDefault(); pick(u); }}><${Avatar} user=${u} size=${22} />${u.name}</button>`)}</div>` : null}
${files.length ? html`<div class="composer-files">${files.map((f, i) => html`<span key=${i} class="chip">${f.name}<button onClick=${() => setFiles(files.filter((_, j) => j !== i))}><${Icon} name="x" size=${10} /></button></span>`)}</div>` : null}
<div class="composer-actions">
<button class="icon-btn" title="Attach files" onClick=${() => fileIn.current.click()}><${Icon} name="paperclip" /></button>
<button class="icon-btn" title="Mention someone" onClick=${() => { setText(text + (text && !text.endsWith(' ') ? ' @' : '@')); setSuggest(''); ta.current.focus(); }}>@</button>
<input type="file" multiple hidden ref=${fileIn} onChange=${(e) => setFiles([...files, ...e.target.files])} />
<span class="spacer" />
${compact ? html`<button class="btn ghost sm" onClick=${() => setOpen(false)}>Cancel</button>` : null}
<button class="btn primary sm" disabled=${busy || (!text.trim() && !files.length)} onClick=${submit}>${busy ? 'Posting…' : parentId ? 'Reply' : 'Update'}</button>
</div>
</div>`;
}
function UpdateCard({ u, replies, reload, itemId, boardId, isReply }) {
const me = state.me;
const author = state.usersById[u.user_id];
const [editing, setEditing] = useState(false);
const [replying, setReplying] = useState(false);
const liked = u.likes?.includes(me.id);
const mine = u.user_id === me.id || me.role === 'admin';
const cardMenu = (e) => menu(e, [
{ label: 'Edit', icon: 'edit', onClick: () => setEditing(true) },
{ label: 'Copy text', icon: 'copy', onClick: () => navigator.clipboard?.writeText(u.body.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')) },
{ label: 'Delete', icon: 'trash', danger: true, onClick: async () => { if (await confirmDialog({ title: 'Delete this update?' })) { await api.del(`/api/updates/${u.id}`); reload(); loadBoard(boardId); } } },
], { align: 'right' });
return html`<article class="update ${isReply ? 'reply' : ''}">
<header class="update-head">
<${Avatar} user=${author || { name: u.author_name || 'Imported', email: u.author_name || '' }} size=${isReply ? 28 : 34} />
<div class="update-meta"><strong>${author?.name || u.author_name || 'Imported'}</strong>
<span class="muted small" title=${new Date(u.created_at).toLocaleString()}>${timeAgo(u.created_at)}${u.edited_at ? ' · edited' : ''}</span></div>
${mine ? html`<button class="icon-btn" onClick=${cardMenu}><${Icon} name="dots" /></button>` : null}
</header>
${editing ? html`<${EditUpdate} u=${u} done=${() => { setEditing(false); reload(); }} />`
: html`<div class="update-body"><${RichText} text=${u.body} /></div>`}
${u.files?.length ? html`<div class="update-files">${u.files.map((f) => isImage(f)
? html`<a key=${f.id} href=${fileUrl(f)} target="_blank" rel="noopener"><img src=${fileUrl(f)} alt=${f.name} loading="lazy" /></a>`
: html`<a key=${f.id} class="file-chip" href=${fileUrl(f)} target="_blank" rel="noopener"><${Icon} name="file" /> ${f.name}</a>`)}</div>` : null}
<footer class="update-actions">
<button class=${liked ? 'on' : ''} onClick=${async () => { await api.post(`/api/updates/${u.id}/like`); reload(); }}>
<${Icon} name="thumb" /> ${liked ? 'Liked' : 'Like'}${u.likes?.length ? ` · ${u.likes.length}` : ''}</button>
${!isReply ? html`<button onClick=${() => setReplying(true)}><${Icon} name="reply" /> Reply</button>` : null}
</footer>
${replies.length || replying ? html`<div class="replies">
${replies.map((r) => html`<${UpdateCard} key=${r.id} u=${r} replies=${[]} reload=${reload} itemId=${itemId} boardId=${boardId} isReply=${true} />`)}
${replying ? html`<${Composer} itemId=${itemId} parentId=${u.id} onPosted=${() => { setReplying(false); reload(); }} boardId=${boardId} compact=${true} autoFocus=${true} />` : null}
</div>` : null}
</article>`;
}
function EditUpdate({ u, done }) {
const [v, setV] = useState(u.body);
return html`<div class="composer"><textarea rows="4" value=${v} onInput=${(e) => setV(e.target.value)} autofocus />
<div class="composer-actions"><span class="spacer" /><button class="btn ghost sm" onClick=${done}>Cancel</button>
<button class="btn primary sm" onClick=${async () => { await api.patch(`/api/updates/${u.id}`, { body: v }); done(); }}>Save</button></div></div>`;
}
// ─── Info (all columns + subitems) ───────────────────────
function Info({ board, item, subs, viewId, detail }) {
const boardId = board.board.id;
const [newSub, setNewSub] = useState('');
const creator = state.usersById[item.created_by];
return html`<div class="info">
<div class="info-grid">
${board.columns.map((c) => html`<div key=${c.id} class="info-row">
<div class="info-label"><span class="type-dot" style=${{ background: COLUMN_TYPES[c.type]?.color }}><${Icon} name=${COLUMN_TYPES[c.type]?.icon || 'edit'} size=${11} /></span>${c.title}</div>
<div class="info-value tc-${c.type}"><${Cell} board=${board} col=${c} item=${item} /></div>
</div>`)}
</div>
${!item.parent_id ? html`<div class="info-subitems">
<h4>Subitems <span class="muted">${subs.length}</span></h4>
${subs.map((s) => {
const status = board.columns.find((c) => c.type === 'status');
return html`<div key=${s.id} class="sub-line">
<button class="link" onClick=${() => navigate(boardPath(boardId, { viewId, itemId: s.id }))}>${s.name}</button>
${status ? html`<div class="sub-status cell-status"><${Cell} board=${board} col=${status} item=${s} /></div>` : null}
</div>`;
})}
<input class="input sm" placeholder="+ Add subitem" value=${newSub} onInput=${(e) => setNewSub(e.target.value)}
onKeyDown=${async (e) => { if (e.key === 'Enter' && newSub.trim()) { const n = newSub.trim(); setNewSub(''); await A.createItem(boardId, { parent_id: item.id, name: n }); } }} />
</div>` : null}
<p class="muted small info-foot">Created ${creator ? `by ${creator.name} ` : ''}on ${fmtDate(ymd(new Date(item.created_at)), { year: true })} · Updated ${timeAgo(item.updated_at)} ago</p>
</div>`;
}
// ─── Files ───────────────────────────────────────────────
function Files({ item, detail, reload, boardId }) {
const input = useRef();
const [busy, setBusy] = useState(false);
const upload = async (list) => {
if (!list?.length) return;
setBusy(true);
const fd = new FormData();
for (const f of list) fd.append('files', f);
try { await api.post(`/api/items/${item.id}/files`, fd); reload(); loadBoard(boardId); } catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
return html`<div class="files-tab" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}>
<button class="dropzone" onClick=${() => input.current.click()}><${Icon} name="upload" size=${24} /><span>${busy ? 'Uploading…' : 'Drag files here or click to upload'}</span></button>
<input type="file" multiple hidden ref=${input} onChange=${(e) => upload(e.target.files)} />
<div class="file-grid">
${detail.files.map((f) => html`<div key=${f.id} class="file-card">
<a href=${fileUrl(f)} target="_blank" rel="noopener" class="file-thumb">${isImage(f) ? html`<img src=${fileUrl(f)} alt="" loading="lazy" />` : html`<span class="file-ext big">${(f.name.split('.').pop() || '').slice(0, 4).toUpperCase()}</span>`}</a>
<div class="file-info"><span class="file-name" title=${f.name}>${f.name}</span><span class="muted small">${fmtSize(f.size)} · ${timeAgo(f.created_at)}</span></div>
<div class="file-actions">
<a class="icon-btn" href=${fileUrl(f, true)} title="Download"><${Icon} name="download" /></a>
<button class="icon-btn" title="Delete" onClick=${async () => { if (await confirmDialog({ title: `Delete ${f.name}?` })) { await api.del(`/api/files/${f.id}`); reload(); loadBoard(boardId); } }}><${Icon} name="trash" /></button>
</div>
</div>`)}
</div>
</div>`;
}
// ─── Activity ────────────────────────────────────────────
export function activityText(a) {
const d = a.data || {};
switch (a.action) {
case 'item_created': return 'created this item';
case 'renamed': return html`renamed from <s>${d.from}</s> to <b>${d.to}</b>`;
case 'moved_group': return html`moved to group <b>${d.to}</b>`;
case 'moved_board': return 'moved from another board';
case 'value': return html`<span class="act-col">${d.column}</span> ${d.from ? html`<span class="act-from">${d.from}</span> → ` : ''}<span class="act-to">${d.to || '(cleared)'}</span>`;
case 'update_posted': return 'posted an update';
case 'file_added': return html`added ${(d.names || []).join(', ')}`;
case 'archived': return 'archived this item';
default: return a.action.replace(/_/g, ' ');
}
}
function Activity({ detail }) {
return html`<div class="activity">
${detail.activity.map((a) => html`<div key=${a.id} class="act-row">
<span class="muted small act-time">${timeAgo(a.created_at)}</span>
<${Avatar} user=${state.usersById[a.user_id] || (a.user_id ? null : { name: 'Automation', email: 'auto' })} size=${24} />
<span class="act-who">${state.usersById[a.user_id]?.name || 'Automation'}</span>
<span class="act-what">${activityText(a)}</span>
</div>`)}
${!detail.activity.length ? html`<p class="muted pad">No activity yet.</p>` : null}
</div>`;
}
+52
View File
@@ -0,0 +1,52 @@
// Kanban: one lane per label of a status column; drag cards between lanes.
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { navigate, boardPath, mutateBoard } from './store.js';
import { Icon, AvatarStack, textOn, fmtDate, fmtRange, Empty } from './ui.js';
import { labelOf, Cell } from './cells.js';
import * as A from './actions.js';
export function KanbanView({ board, view, items, hidden }) {
const boardId = board.board.id;
const statusCols = board.columns.filter((c) => c.type === 'status');
const col = statusCols.find((c) => c.id === view.settings?.column) || statusCols[0];
const [drag, setDrag] = useState(null);
const [over, setOver] = useState(null);
const [adding, setAdding] = useState(null);
if (!col) return html`<${Empty} icon="kanban" title="Kanban needs a status column">
<button class="btn primary" onClick=${() => A.addColumn(boardId, 'status')}>Add a status column</button><//>`;
const lanes = [...(col.settings.labels || []), { id: null, name: 'Empty', color: '#c4c4c4' }];
const people = board.columns.find((c) => c.type === 'people');
const date = board.columns.find((c) => c.type === 'date' || c.type === 'timeline');
const extra = board.columns.filter((c) => !hidden.includes(c.id) && c !== col && c !== people && c !== date && ['text', 'number', 'dropdown', 'tags'].includes(c.type)).slice(0, 3);
const setCol = (id) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, column: id }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, column: id } }); };
return html`<div class="kanban-wrap">
${statusCols.length > 1 ? html`<div class="view-options"><span class="muted small">Lanes from</span>
<select class="input sm" value=${col.id} onChange=${(e) => setCol(e.target.value)}>${statusCols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select></div>` : null}
<div class="kanban">
${lanes.map((l) => {
const cards = items.filter((it) => (it.vals[col.id] || null) === l.id);
if (l.id === null && !cards.length) return null;
return html`<div key=${l.id || 'none'} class="lane ${over === l.id ? 'over' : ''}"
onDragOver=${(e) => { e.preventDefault(); setOver(l.id); }} onDragLeave=${() => setOver(null)}
onDrop=${(e) => { e.preventDefault(); setOver(null); if (drag && (drag.vals[col.id] || null) !== l.id) A.setValue(boardId, drag.id, col.id, l.id); setDrag(null); }}>
<div class="lane-head" style=${{ background: l.color, color: textOn(l.color) }}>${l.name} <span>${cards.length}</span></div>
<div class="lane-body">
${cards.map((it) => html`<div key=${it.id} class="kcard" draggable="true" onDragStart=${() => setDrag(it)} onDragEnd=${() => setDrag(null)}
onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>
<div class="kcard-name">${it.name}</div>
${extra.map((c) => it.vals[c.id] != null ? html`<div key=${c.id} class="kcard-field"><span class="muted small">${c.title}</span><div onClick=${(e) => e.stopPropagation()}><${Cell} board=${board} col=${c} item=${it} compact /></div></div>` : null)}
<div class="kcard-foot">
${date && it.vals[date.id] ? html`<span class="muted small"><${Icon} name="calendar" size=${12} /> ${date.type === 'date' ? fmtDate(it.vals[date.id]) : fmtRange(it.vals[date.id])}</span>` : html`<span />`}
${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${22} />` : null}
</div>
</div>`)}
${adding === l.id ? html`<input class="input sm" autofocus placeholder="Name"
onKeyDown=${async (e) => { if (e.key === 'Enter' && e.target.value.trim()) { const name = e.target.value.trim(); e.target.value = ''; await A.createItem(boardId, { group_id: board.groups[0]?.id, name, vals: l.id ? { [col.id]: l.id } : {} }); } if (e.key === 'Escape') setAdding(undefined); }}
onBlur=${() => setAdding(undefined)} />`
: html`<button class="lane-add" onClick=${() => setAdding(l.id)}><${Icon} name="plus" /> Add ${board.board.item_label.toLowerCase()}</button>`}
</div>
</div>`;
})}
</div></div>`;
}
+291
View File
@@ -0,0 +1,291 @@
// Top-level pages: Home, My work, Inbox, Search, Team, Workspace, Admin, and the user menu.
import { html, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, navigate, boardPath, loadUsers, loadTree, toast, setTheme } from './store.js';
import { Icon, Avatar, Spinner, Empty, timeAgo, fmtDate, parseDate, ymd, todayStr, textOn, openModal, closePopover, Menu, RichText, useMobile, confirmDialog, promptDialog } from './ui.js';
import { NewBoardModal } from './sidebar.js';
function PageHead({ title, sub, children }) {
const mobile = useMobile();
return html`<header class="page-head">
${mobile ? html`<button class="icon-btn" onClick=${() => update({ sidebarOpen: true })}><${Icon} name="menu" size=${20} /></button>` : null}
<div class="grow"><h1>${title}</h1>${sub ? html`<p class="muted">${sub}</p>` : null}</div>${children}</header>`;
}
// ─── Home ────────────────────────────────────────────────
export function HomePage() {
const s = useStore();
const [work, setWork] = useState(null);
useEffect(() => { api.get('/api/mywork').then(setWork).catch(() => setWork([])); }, []);
const hour = new Date().getHours();
const greet = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
const favs = s.boards.filter((b) => s.favorites.includes(b.id));
const recent = s.boards.slice().reverse().slice(0, 8);
const open = (work || []).filter((w) => !w.status?.done);
const overdue = open.filter((w) => w.date && w.date.slice(0, 10) < todayStr()).length;
const today = open.filter((w) => w.date && w.date.slice(0, 10) === todayStr()).length;
const wsName = (id) => s.workspaces.find((w) => w.id === id)?.name;
return html`<div class="page home">
<${PageHead} title=${`${greet}, ${s.me.name.split(' ')[0]}!`} sub="Quickly access your recent boards, inbox and work." />
<div class="home-grid">
<section class="card home-stats">
<button class="stat" onClick=${() => navigate('/my-work')}><span class="stat-n">${work ? open.length : '–'}</span><span class="muted">Open items assigned to you</span></button>
<button class="stat" onClick=${() => navigate('/my-work')}><span class="stat-n" style=${{ color: overdue ? 'var(--red)' : undefined }}>${work ? overdue : '–'}</span><span class="muted">Overdue</span></button>
<button class="stat" onClick=${() => navigate('/my-work')}><span class="stat-n">${work ? today : '–'}</span><span class="muted">Due today</span></button>
<button class="stat" onClick=${() => navigate('/inbox')}><span class="stat-n">${s.unread}</span><span class="muted">Unread notifications</span></button>
</section>
${favs.length ? html`<section><h3 class="section-title"><${Icon} name="star" /> Favorites</h3>
<div class="board-cards">${favs.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${wsName(b.workspace_id)} />`)}</div></section>` : null}
<section><h3 class="section-title">Boards</h3>
<div class="board-cards">
${recent.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${wsName(b.workspace_id)} />`)}
<button class="board-card new" onClick=${() => openModal(NewBoardModal, { workspaceId: s.currentWorkspace || s.workspaces[0]?.id })}><${Icon} name="plus" size=${28} /><span>New board</span></button>
</div></section>
</div>
</div>`;
}
function BoardCard({ b, ws }) {
return html`<button class="board-card" onClick=${() => navigate(boardPath(b.id))}>
<div class="bc-preview"><i /><i /><i /><i /></div>
<div class="bc-name"><${Icon} name="board" size=${14} /> ${b.name}</div>
<div class="muted small">${ws}</div></button>`;
}
// ─── My work ─────────────────────────────────────────────
export function MyWorkPage() {
const s = useStore();
const [items, setItems] = useState(null);
const [showDone, setShowDone] = useState(false);
const [q, setQ] = useState('');
const [who, setWho] = useState(s.route.q?.user || s.me.id);
const load = () => api.get(`/api/mywork${who !== s.me.id ? `?user=${who}` : ''}`).then(setItems);
useEffect(() => { setItems(null); load(); }, [who]);
useEffect(() => { const fn = () => load(); window.addEventListener('wb:board-changed', fn); return () => window.removeEventListener('wb:board-changed', fn); }, [who]);
const t = todayStr();
const d = new Date(); const endWeek = new Date(d); endWeek.setDate(d.getDate() + (7 - d.getDay()));
const endNext = new Date(endWeek); endNext.setDate(endWeek.getDate() + 7);
const buckets = [
['Past dates', (x) => x < t, 'var(--red)'], ['Today', (x) => x === t, 'var(--green)'], ['This week', (x) => x > t && x <= ymd(endWeek), 'var(--primary)'],
['Next week', (x) => x > ymd(endWeek) && x <= ymd(endNext), '#a25ddc'], ['Later', (x) => x > ymd(endNext), '#fdab3d'],
];
const list = (items || []).filter((i) => (showDone || !i.status?.done) && (!q || (i.name + i.board_name).toLowerCase().includes(q.toLowerCase())));
const toggleDone = async (it) => {
// Flip to the first "done" label (or back to empty).
const data = state.boardData[it.board_id] || await api.get(`/api/boards/${it.board_id}`);
const col = data.columns.find((c) => c.id === it.status_column);
if (!col) return toast('That board has no status column', 'error');
const doneLabel = col.settings.labels.find((l) => l.done || /^done$/i.test(l.name));
if (!doneLabel) return toast(`"${col.title}" has no Done label`, 'error');
await api.patch(`/api/items/${it.id}`, { vals: { [col.id]: it.status?.done ? null : doneLabel.id } });
load();
};
const Row = ({ it }) => html`<div class="mw-row ${it.status?.done ? 'done' : ''}">
<button class="mw-check ${it.status?.done ? 'on' : ''}" onClick=${() => toggleDone(it)} title="Mark done"><${Icon} name="check" size=${12} /></button>
<button class="mw-name" onClick=${() => navigate(boardPath(it.board_id, { itemId: it.id }))}>${it.name}${it.parent_name ? html`<span class="muted small"> · in ${it.parent_name}</span>` : null}</button>
<span class="mw-board muted small"><span class="dot" style=${{ background: it.group_color }} />${it.board_name}${it.group ? ` › ${it.group}` : ''}</span>
${it.status ? html`<span class="mw-status" style=${{ background: it.status.color, color: textOn(it.status.color) }}>${it.status.name}</span>` : html`<span class="mw-status empty" />`}
<span class="mw-date ${it.date && it.date.slice(0, 10) < t && !it.status?.done ? 'overdue' : ''}">${it.date ? fmtDate(it.date) : ''}</span>
</div>`;
return html`<div class="page mywork">
<${PageHead} title="My work" sub="Everything assigned to you, across every board.">
<select class="input sm" value=${who} onChange=${(e) => setWho(e.target.value)} title="View someone else's work">
${s.users.filter((u) => u.active).map((u) => html`<option value=${u.id}>${u.id === s.me.id ? 'Me' : u.name}</option>`)}</select>
<//>
<div class="toolbar">
<div class="tb-search"><${Icon} name="search" /><input placeholder="Search" value=${q} onInput=${(e) => setQ(e.target.value)} /></div>
<label class="check-row"><input type="checkbox" checked=${showDone} onChange=${(e) => setShowDone(e.target.checked)} /> Show done items</label>
</div>
${!items ? html`<${Spinner} />` : !list.length ? html`<${Empty} icon="work" title="You're all caught up"><p class="muted">Items show up here when someone assigns you in a People column.</p><//>` : html`
${buckets.map(([title, test, color]) => { const rows = list.filter((i) => i.date && test(i.date.slice(0, 10))).sort((a, b) => a.date.localeCompare(b.date));
return rows.length ? html`<section key=${title} class="mw-group"><h3 style=${{ color }}>${title} <span class="muted small">${rows.length}</span></h3>${rows.map((it) => html`<${Row} key=${it.id} it=${it} />`)}</section>` : null; })}
${(() => { const rows = list.filter((i) => !i.date); return rows.length ? html`<section class="mw-group"><h3 style=${{ color: 'var(--muted)' }}>Without a date <span class="muted small">${rows.length}</span></h3>${rows.map((it) => html`<${Row} key=${it.id} it=${it} />`)}</section>` : null; })()}
`}
</div>`;
}
// ─── Inbox ───────────────────────────────────────────────
export function InboxPage() {
const s = useStore();
const [rows, setRows] = useState(null);
const [filter, setFilter] = useState('all');
const load = () => api.get('/api/notifications').then(setRows);
useEffect(() => { load(); const fn = () => load(); window.addEventListener('wb:notifications', fn); return () => window.removeEventListener('wb:notifications', fn); }, []);
const mark = async (ids, unread = false) => { const r = await api.post('/api/notifications/read', { ids, unread }); update({ unread: r.unread }); load(); };
const markAll = async () => { const r = await api.post('/api/notifications/read', { all: true }); update({ unread: r.unread }); load(); };
const shown = (rows || []).filter((n) => filter === 'all' || (filter === 'unread' ? !n.read : n.kind === filter));
const icon = { mention: 'message', update: 'message', reply: 'reply', assigned: 'person', like: 'thumb', automation: 'bolt' };
return html`<div class="page inbox">
<${PageHead} title="Inbox" sub="Mentions, assignments, replies and automation alerts.">
<button class="btn ghost sm" onClick=${markAll} disabled=${!s.unread}>Mark all as read</button>
<//>
<div class="row gap wrap">${[['all', 'All'], ['unread', 'Unread'], ['mention', 'Mentions'], ['assigned', 'Assigned'], ['automation', 'Automations']].map(([k, l]) => html`
<button key=${k} class="chip-btn ${filter === k ? 'on' : ''}" onClick=${() => setFilter(k)}>${l}</button>`)}</div>
${!rows ? html`<${Spinner} />` : !shown.length ? html`<${Empty} icon="bell" title="Nothing new"><p class="muted">When someone mentions or assigns you, it shows up here.</p><//>` : html`<div class="notif-list">
${shown.map((n) => html`<div key=${n.id} class="notif ${n.read ? '' : 'unread'}" onClick=${() => { if (!n.read) mark([n.id]); if (n.board_id) navigate(boardPath(n.board_id, { itemId: n.item_id || undefined })); }}>
<div class="notif-av"><${Avatar} user=${s.usersById[n.actor_id] || (n.actor_id ? null : { name: '⚡', email: 'auto' })} size=${36} /><span class="notif-kind"><${Icon} name=${icon[n.kind] || 'bell'} size=${11} /></span></div>
<div class="notif-body"><div><${RichText} text=${n.text.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')} /></div>
<div class="muted small">${n.board_name || ''}${n.item_name ? ` › ${n.item_name}` : ''} · ${timeAgo(n.created_at)}</div></div>
<button class="icon-btn" title=${n.read ? 'Mark unread' : 'Mark read'} onClick=${(e) => { e.stopPropagation(); mark([n.id], !!n.read); }}><span class="read-dot ${n.read ? '' : 'on'}" /></button>
</div>`)}</div>`}
</div>`;
}
// ─── Search ──────────────────────────────────────────────
export function SearchPage() {
const s = useStore();
const [q, setQ] = useState(s.route.q?.q || '');
const [res, setRes] = useState(null);
const t = useRef();
useEffect(() => {
clearTimeout(t.current);
if (q.trim().length < 2) { setRes(null); return; }
t.current = setTimeout(() => { api.get(`/api/search?q=${encodeURIComponent(q)}`).then(setRes); history.replaceState({}, '', `/search?q=${encodeURIComponent(q)}`); }, 200);
}, [q]);
return html`<div class="page search-page">
<${PageHead} title="Search everything" />
<div class="big-search"><${Icon} name="search" size=${20} /><input placeholder="Search items, boards and updates" value=${q} onInput=${(e) => setQ(e.target.value)} autofocus /></div>
${res ? html`
${res.boards.length ? html`<h3 class="section-title">Boards</h3>${res.boards.map((b) => html`<button key=${b.id} class="result" onClick=${() => navigate(boardPath(b.id))}><${Icon} name="board" /> ${b.name}</button>`)}` : null}
${res.items.length ? html`<h3 class="section-title">Items</h3>${res.items.map((i) => html`<button key=${i.id} class="result" onClick=${() => navigate(boardPath(i.board_id, { itemId: i.id }))}>
<span class="dot" style=${{ background: i.group_color || 'var(--muted)' }} /><span class="grow">${i.name}</span><span class="muted small">${i.board_name}${i.group_title ? ` › ${i.group_title}` : ''}</span></button>`)}` : null}
${res.updates.length ? html`<h3 class="section-title">Updates</h3>${res.updates.map((u) => html`<button key=${u.id} class="result update-result" onClick=${() => navigate(boardPath(u.board_id, { itemId: u.item_id }))}>
<${Avatar} user=${s.usersById[u.user_id]} size=${24} /><span class="grow clamp2">${u.body.replace(/@\[([^\]]+)\]\([\w-]+\)/g, '@$1')}</span><span class="muted small">${u.item_name} · ${timeAgo(u.created_at)}</span></button>`)}` : null}
${!res.boards.length && !res.items.length && !res.updates.length ? html`<p class="muted pad">No results for "${q}".</p>` : null}
` : html`<p class="muted pad">Type at least two characters.</p>`}
</div>`;
}
// ─── Team ────────────────────────────────────────────────
export function PeoplePage() {
const s = useStore();
const [q, setQ] = useState('');
useEffect(() => { loadUsers(); }, []);
const users = s.users.filter((u) => (u.name + u.email).toLowerCase().includes(q.toLowerCase())).sort((a, b) => b.active - a.active);
return html`<div class="page people">
<${PageHead} title="Team" sub=${`${s.users.filter((u) => u.active).length} people`}>
${s.me.role === 'admin' ? html`<button class="btn primary sm" onClick=${async () => {
const email = await promptDialog({ title: 'Add a teammate', label: 'Their company email — they sign in with Google the first time they visit.', placeholder: 'name@waterschurch.org', confirm: 'Add' });
if (email) { try { await api.post('/api/users', { email }); loadUsers(); toast('Added'); } catch (e) { toast(e.message, 'error'); } }
}}><${Icon} name="plus" /> Add person</button>` : null}
<//>
<div class="tb-search wide"><${Icon} name="search" /><input placeholder="Search people" value=${q} onInput=${(e) => setQ(e.target.value)} /></div>
<div class="people-grid">${users.map((u) => html`<div key=${u.id} class="person-card ${u.active ? '' : 'inactive'}">
<${Avatar} user=${u} size=${56} />
<strong>${u.name}</strong><span class="muted small">${u.title || (u.role === 'admin' ? 'Admin' : 'Member')}</span>
<a class="muted small" href=${'mailto:' + u.email}>${u.email}</a>
<button class="btn ghost sm" onClick=${() => navigate(`/my-work?user=${u.id}`)}>Assigned work</button>
</div>`)}</div>
</div>`;
}
// ─── Workspace overview ──────────────────────────────────
export function WorkspacePage({ workspaceId }) {
const s = useStore();
const ws = s.workspaces.find((w) => w.id === workspaceId);
if (!ws) return html`<${Empty} title="Workspace not found" />`;
const boards = s.boards.filter((b) => b.workspace_id === ws.id);
return html`<div class="page">
<${PageHead} title=${ws.name} sub=${ws.description || `${boards.length} boards`}>
<button class="btn primary sm" onClick=${() => openModal(NewBoardModal, { workspaceId: ws.id })}><${Icon} name="plus" /> New board</button><//>
<div class="board-cards">${boards.map((b) => html`<${BoardCard} key=${b.id} b=${b} ws=${s.folders.find((f) => f.id === b.folder_id)?.name || ''} />`)}</div>
</div>`;
}
// ─── Admin ───────────────────────────────────────────────
export function AdminPage() {
const s = useStore();
const [tab, setTab] = useState(s.route.tab || 'people');
useEffect(() => { loadUsers(); }, []);
if (s.me.role !== 'admin') return html`<${Empty} icon="shield" title="Admins only" />`;
return html`<div class="page admin">
<${PageHead} title="Administration" />
<nav class="panel-tabs">
<button class=${tab === 'people' ? 'active' : ''} onClick=${() => setTab('people')}>People & access</button>
<button class=${tab === 'import' ? 'active' : ''} onClick=${() => setTab('import')}>Import from monday.com</button>
</nav>
${tab === 'people' ? html`<${AdminPeople} />` : html`<${MondayImport} />`}
</div>`;
}
function AdminPeople() {
const s = useStore();
const patch = async (u, p) => { try { await api.patch(`/api/users/${u.id}`, p); loadUsers(); } catch (e) { toast(e.message, 'error'); } };
return html`<div class="card"><table class="admin-table">
<thead><tr><th>Person</th><th>Email</th><th>Role</th><th>Last seen</th><th>Access</th></tr></thead>
<tbody>${s.users.map((u) => html`<tr key=${u.id} class=${u.active ? '' : 'inactive'}>
<td><span class="row gap"><${Avatar} user=${u} size=${26} />${u.name}</span></td>
<td class="muted">${u.email}</td>
<td><select class="input sm" value=${u.role} disabled=${u.id === s.me.id} onChange=${(e) => patch(u, { role: e.target.value })}><option value="member">Member</option><option value="admin">Admin</option></select></td>
<td class="muted small">${u.last_seen ? timeAgo(u.last_seen) : 'never signed in'}</td>
<td>${u.id === s.me.id ? html`<span class="muted small">you</span>` : html`<button class="btn ghost sm" onClick=${async () => {
if (u.active && !(await confirmDialog({ title: `Remove ${u.name}'s access?`, body: 'They are signed out everywhere and can no longer sign in. Their items and updates stay.', confirm: 'Remove access' }))) return;
patch(u, { active: !u.active });
}}>${u.active ? 'Deactivate' : 'Reactivate'}</button>`}</td>
</tr>`)}</tbody></table>
<p class="muted small pad">Anyone with an allowed company Google account can sign in and becomes a member. Admins can manage people, delete workspaces and run imports.</p>
</div>`;
}
function MondayImport() {
const [token, setToken] = useState('');
const [boards, setBoards] = useState(null);
const [sel, setSel] = useState([]);
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
const poll = useRef();
const refresh = () => api.get('/api/admin/import/monday').then((st) => { setStatus(st); if (!st.running) { clearInterval(poll.current); if (st.finishedAt) loadTree(); } });
useEffect(() => { refresh(); return () => clearInterval(poll.current); }, []);
const list = async () => {
setBusy(true);
try { const b = await api.post('/api/admin/import/monday/boards', { token }); setBoards(b); setSel(b.filter((x) => !x.imported).map((x) => x.id)); }
catch (e) { toast(e.message, 'error'); }
setBusy(false);
};
const start = async () => {
try { await api.post('/api/admin/import/monday', { token, boardIds: sel }); clearInterval(poll.current); poll.current = setInterval(refresh, 1500); refresh(); }
catch (e) { toast(e.message, 'error'); }
};
return html`<div class="card import-card">
<h3>Bring your boards over from monday.com</h3>
<ol class="steps">
<li>In monday.com, click your avatar → <b>Developers</b> → <b>My access tokens</b> → <b>Show</b>, and copy the token.</li>
<li>Paste it below and load your boards. Pick which ones to bring over.</li>
<li>Everything comes across: workspaces, folders, groups, columns, items, subitems, updates and files. People are matched by email and claim their assignments the first time they sign in.</li>
</ol>
<div class="row gap"><input class="input grow" type="password" placeholder="monday.com API token" value=${token} onInput=${(e) => setToken(e.target.value)} autocomplete="off" />
<button class="btn" disabled=${!token || busy} onClick=${list}>${busy ? 'Loading…' : 'Load boards'}</button></div>
<p class="muted small">The token is only used for this import and is never stored.</p>
${boards ? html`<div class="import-list">
<label class="check-row"><input type="checkbox" checked=${sel.length === boards.filter((b) => !b.imported).length} onChange=${(e) => setSel(e.target.checked ? boards.filter((b) => !b.imported).map((b) => b.id) : [])} /> <b>All boards</b> (${boards.length})</label>
${boards.map((b) => html`<label key=${b.id} class="check-row ${b.imported ? 'muted' : ''}">
<input type="checkbox" disabled=${b.imported} checked=${sel.includes(b.id)} onChange=${(e) => setSel(e.target.checked ? [...sel, b.id] : sel.filter((x) => x !== b.id))} />
${b.name} <span class="muted small">${b.workspace} · ${b.items ?? '?'} items${b.imported ? ' · already imported' : ''}</span></label>`)}
</div>
<button class="btn primary" disabled=${!sel.length || status?.running} onClick=${start}>Import ${sel.length} board${sel.length === 1 ? '' : 's'}</button>` : null}
${status && (status.running || status.log.length) ? html`<div class="import-log">
<div class="row gap"><strong>${status.running ? 'Importing…' : status.error ? 'Import failed' : 'Import finished'}</strong>${status.running ? html`<${Spinner} />` : null}
<span class="muted small">${status.counts.boards || 0} boards · ${status.counts.items || 0} items · ${status.counts.updates || 0} updates · ${status.counts.files || 0} files</span></div>
<pre>${status.log.slice(-200).join('\n')}</pre></div>` : null}
</div>`;
}
// ─── User menu ───────────────────────────────────────────
export function UserMenu() {
const s = useStore();
const signOut = async () => { await api.post('/api/auth/logout'); location.href = '/'; };
return html`<div class="user-menu">
<div class="um-head"><${Avatar} user=${s.me} size=${40} /><div><strong>${s.me.name}</strong><div class="muted small">${s.me.email}</div></div></div>
<${Menu} items=${[
{ label: 'Edit my name', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Your name', value: s.me.name }); if (n) { const me = await api.patch('/api/me', { name: n }); update({ me }); loadUsers(); } } },
{ label: 'My work', icon: 'work', onClick: () => navigate('/my-work') },
{ divider: true },
{ label: 'Light theme', icon: 'sun', active: s.theme === 'light', onClick: () => setTheme('light') },
{ label: 'Dark theme', icon: 'moon', active: s.theme === 'dark', onClick: () => setTheme('dark') },
{ label: 'Night theme', icon: 'moon', active: s.theme === 'night', onClick: () => setTheme('night') },
{ label: 'Match system', icon: 'gear', active: s.theme === 'system', onClick: () => setTheme('system') },
{ divider: true },
s.me.role === 'admin' && { label: 'Administration', icon: 'shield', onClick: () => navigate('/admin') },
{ label: 'Sign out', icon: 'logout', onClick: signOut },
]} />
</div>`;
}
+165
View File
@@ -0,0 +1,165 @@
// Left navigation: global nav rail + workspace tree (folders, boards, favorites).
import { html, useState } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, navigate, boardPath, loadTree, localSet, localGet } from './store.js';
import { Icon, Avatar, menu, openModal, closeModal, ModalHeader, promptDialog, confirmDialog, openPopover, ColorPicker, GROUP_COLORS } from './ui.js';
import * as A from './actions.js';
import { UserMenu } from './pages.js';
export function Sidebar() {
const s = useStore();
const [q, setQ] = useState('');
const [openFolders, setOpenFolders] = useState(() => { try { return JSON.parse(localGet('wb.openFolders') || '{}'); } catch { return {}; } });
const ws = s.workspaces.find((w) => w.id === s.currentWorkspace) || s.workspaces[0];
const r = s.route;
const toggleFolder = (id) => { const n = { ...openFolders, [id]: !openFolders[id] }; setOpenFolders(n); localSet('wb.openFolders', JSON.stringify(n)); };
const pickWs = (id) => { localSet('wb.workspace', id); update({ currentWorkspace: id }); };
const favs = s.boards.filter((b) => s.favorites.includes(b.id));
const filter = q.trim().toLowerCase();
const wsBoards = s.boards.filter((b) => b.workspace_id === ws?.id && (!filter || b.name.toLowerCase().includes(filter)));
const wsFolders = s.folders.filter((f) => f.workspace_id === ws?.id);
const wsMenu = (e) => menu(e, [
...s.workspaces.map((w) => ({ label: w.name, icon: 'workspace', active: w.id === ws?.id, onClick: () => pickWs(w.id) })),
{ divider: true },
{ label: 'Add workspace', icon: 'plus', onClick: async () => {
const name = await promptDialog({ title: 'New workspace', placeholder: 'e.g. Production, Creative, Facilities', confirm: 'Create' });
if (name) { const w = await api.post('/api/workspaces', { name, color: GROUP_COLORS[s.workspaces.length % GROUP_COLORS.length] }); await loadTree(); pickWs(w.id); }
} },
ws && { label: 'Workspace overview', icon: 'info', onClick: () => navigate(`/workspaces/${ws.id}`) },
ws && { label: 'Rename workspace', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename workspace', value: ws.name }); if (n) { await api.patch(`/api/workspaces/${ws.id}`, { name: n }); loadTree(); } } },
ws && s.me.role === 'admin' && s.workspaces.length > 1 && { label: 'Delete workspace', icon: 'trash', danger: true, onClick: async () => {
if (await confirmDialog({ title: `Delete workspace "${ws.name}"?`, body: 'Every board, item and file in it is deleted permanently.' })) { await api.del(`/api/workspaces/${ws.id}`); await loadTree(); pickWs(s.workspaces[0]?.id); }
} },
]);
const addMenu = (e) => menu(e, [
{ label: 'New board', icon: 'board', onClick: () => openModal(NewBoardModal, { workspaceId: ws.id }) },
{ label: 'New folder', icon: 'folder', onClick: () => A.newFolder(ws.id) },
]);
const BoardLink = ({ b, depth = 0 }) => html`<div class="nav-board ${r.boardId === b.id ? 'active' : ''}" style=${{ paddingLeft: 12 + depth * 14 }}
draggable="true" onDragStart=${(e) => { e.dataTransfer.setData('wb/board', b.id); }}
onClick=${() => navigate(boardPath(b.id))}
onContextMenu=${(e) => boardMenu(e, b)}>
<${Icon} name="board" size=${15} /><span class="nav-label">${b.name}</span>
<button class="icon-btn nav-more" onClick=${(e) => boardMenu(e, b)}><${Icon} name="dots" size=${14} /></button>
</div>`;
const boardMenu = (e, b) => menu(e, [
{ label: 'Open in new tab', icon: 'expand', onClick: () => window.open(boardPath(b.id), '_blank') },
{ label: 'Rename', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename board', value: b.name }); if (n) { await A.renameBoard(b.id, n); loadTree(); } } },
{ label: s.favorites.includes(b.id) ? 'Remove from favorites' : 'Add to favorites', icon: 'star', onClick: () => A.toggleFavorite(b.id).then(loadTree) },
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateBoard(b.id) },
{ label: 'Move to folder', icon: 'folder', keepOpen: true, onClick: (ev) => menu(ev, [{ label: '(no folder)', onClick: () => moveBoard(b, null) },
...wsFolders.map((f) => ({ label: f.name, onClick: () => moveBoard(b, f.id) }))]) },
{ divider: true },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteBoard(b.id) },
]);
const moveBoard = async (b, folderId) => { await api.patch(`/api/boards/${b.id}`, { folder_id: folderId }); loadTree(); };
const Folder = ({ f, depth = 0 }) => {
const open = openFolders[f.id] || !!filter;
const kids = wsFolders.filter((x) => x.parent_id === f.id);
const boards = wsBoards.filter((b) => b.folder_id === f.id);
if (filter && !boards.length && !kids.length) return null;
const fMenu = (e) => menu(e, [
{ label: 'New board here', icon: 'board', onClick: () => openModal(NewBoardModal, { workspaceId: ws.id, folderId: f.id }) },
{ label: 'New subfolder', icon: 'folder', onClick: () => A.newFolder(ws.id, f.id) },
{ label: 'Rename', icon: 'edit', onClick: () => A.renameFolder(f) },
{ label: 'Color', icon: 'gear', keepOpen: true, onClick: (ev) => openPopover(ev.currentTarget, ColorPicker, { value: f.color, onPick: async (c) => { await api.patch(`/api/folders/${f.id}`, { color: c }); loadTree(); } }) },
{ divider: true },
{ label: 'Delete folder', icon: 'trash', danger: true, onClick: () => A.deleteFolder(f) },
]);
return html`<div class="nav-folder">
<div class="nav-folder-row" style=${{ paddingLeft: 8 + depth * 14 }} onClick=${() => toggleFolder(f.id)} onContextMenu=${fMenu}
onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { const id = e.dataTransfer.getData('wb/board'); if (id) moveBoard({ id }, f.id); }}>
<${Icon} name=${open ? 'chevDown' : 'chevRight'} size=${13} />
<span style=${{ color: f.color || undefined }}><${Icon} name=${open ? 'folderOpen' : 'folder'} size=${15} /></span>
<span class="nav-label">${f.name}</span>
<button class="icon-btn nav-more" onClick=${fMenu}><${Icon} name="dots" size=${14} /></button>
</div>
${open ? html`${kids.map((k) => html`<${Folder} key=${k.id} f=${k} depth=${depth + 1} />`)}${boards.map((b) => html`<${BoardLink} key=${b.id} b=${b} depth=${depth + 1} />`)}` : null}
</div>`;
};
return html`<aside class="sidebar ${s.sidebarOpen ? 'open' : ''} ${s.sidebarCollapsed ? 'collapsed' : ''}">
<div class="rail">
<button class="logo" onClick=${() => navigate('/')} title="Home"><span class="logo-mark"><i /><i /><i /></span></button>
<${RailBtn} icon="home" label="Home" active=${r.name === 'home'} onClick=${() => navigate('/')} />
<${RailBtn} icon="work" label="My work" active=${r.name === 'my-work'} onClick=${() => navigate('/my-work')} />
<${RailBtn} icon="bell" label="Inbox" active=${r.name === 'inbox'} onClick=${() => navigate('/inbox')} badge=${s.unread} />
<${RailBtn} icon="search" label="Search" active=${r.name === 'search'} onClick=${() => navigate('/search')} />
<${RailBtn} icon="people" label="Team" active=${r.name === 'people'} onClick=${() => navigate('/people')} />
<span class="spacer" />
${s.me?.role === 'admin' ? html`<${RailBtn} icon="shield" label="Admin" active=${r.name === 'admin'} onClick=${() => navigate('/admin')} />` : null}
<button class="rail-me" onClick=${(e) => openPopover(e.currentTarget, UserMenu, {}, {})} title=${s.me?.name}><${Avatar} user=${s.me} size=${32} /></button>
</div>
<div class="nav-panel">
<div class="nav-head">
<button class="ws-picker" onClick=${wsMenu}>
<span class="ws-icon" style=${{ background: ws?.color || '#579bfc' }}>${(ws?.name || '?')[0]}</span>
<span class="nav-label">${ws?.name || 'Workspace'}</span><${Icon} name="chevDown" size=${14} />
</button>
<button class="icon-btn" onClick=${addMenu} title="Add"><${Icon} name="plus" /></button>
<button class="icon-btn collapse-btn" onClick=${() => { localSet('wb.sidebarCollapsed', '1'); update({ sidebarCollapsed: true }); }} title="Collapse"><${Icon} name="chevLeft" /></button>
</div>
<div class="nav-search"><${Icon} name="search" size=${14} /><input placeholder="Search boards" value=${q} onInput=${(e) => setQ(e.target.value)} /></div>
<div class="nav-scroll">
${favs.length && !filter ? html`<div class="nav-section"><div class="nav-section-title"><${Icon} name="star" size=${13} /> Favorites</div>
${favs.map((b) => html`<${BoardLink} key=${'f' + b.id} b=${b} />`)}</div>` : null}
<div class="nav-section" onDragOver=${(e) => e.preventDefault()} onDrop=${(e) => { const id = e.dataTransfer.getData('wb/board'); if (id && e.target.classList.contains('nav-section-title')) moveBoard({ id }, null); }}>
<div class="nav-section-title">Boards</div>
${wsFolders.filter((f) => !f.parent_id).map((f) => html`<${Folder} key=${f.id} f=${f} />`)}
${wsBoards.filter((b) => !b.folder_id || !wsFolders.some((f) => f.id === b.folder_id)).map((b) => html`<${BoardLink} key=${b.id} b=${b} />`)}
${!wsBoards.length && !filter ? html`<button class="nav-empty" onClick=${() => openModal(NewBoardModal, { workspaceId: ws.id })}><${Icon} name="plus" /> Create your first board</button>` : null}
</div>
</div>
</div>
${s.sidebarCollapsed ? html`<button class="expand-btn" onClick=${() => { localSet('wb.sidebarCollapsed', null); update({ sidebarCollapsed: false }); }} title="Expand"><${Icon} name="chevRight" /></button>` : null}
</aside>
${s.sidebarOpen ? html`<div class="drawer-backdrop" onClick=${() => update({ sidebarOpen: false })} />` : null}`;
}
function RailBtn({ icon, label, active, onClick, badge }) {
return html`<button class="rail-btn ${active ? 'active' : ''}" onClick=${onClick} title=${label}>
<${Icon} name=${icon} size=${20} />${badge ? html`<span class="rail-badge">${badge > 99 ? '99+' : badge}</span>` : null}<span class="rail-label">${label}</span></button>`;
}
const TEMPLATES = [
['blank', 'Blank board', 'Owner, Status and Due date to start', 'board'],
['project', 'Project tracker', 'Owner, status, priority, timeline', 'timeline'],
['inventory', 'Equipment inventory', 'Model, serial #, location, condition, files', 'archive'],
['content', 'Content calendar', 'Owner, status, publish date, channel, caption', 'calendar'],
['requests', 'Requests', 'Intake for requests with priority and due date', 'inbox'],
];
export function NewBoardModal({ workspaceId, folderId }) {
const s = useStore();
const [name, setName] = useState('');
const [tpl, setTpl] = useState('blank');
const [ws, setWs] = useState(workspaceId);
const [folder, setFolder] = useState(folderId || '');
const [busy, setBusy] = useState(false);
const create = async (e) => {
e.preventDefault();
setBusy(true);
try {
const r = await api.post('/api/boards', { workspace_id: ws, folder_id: folder || null, name: name.trim() || 'New board', template: tpl });
await loadTree(); closeModal(); navigate(boardPath(r.id));
} finally { setBusy(false); }
};
return html`<form class="dialog" onSubmit=${create}><${ModalHeader} title="Create board" />
<label class="field-label">Board name</label>
<input class="input" value=${name} onInput=${(e) => setName(e.target.value)} placeholder="New board" autofocus />
<label class="field-label">Start from</label>
<div class="tpl-grid">${TEMPLATES.map(([k, l, d, i]) => html`<button type="button" key=${k} class="tpl ${tpl === k ? 'on' : ''}" onClick=${() => setTpl(k)}>
<${Icon} name=${i} size=${20} /><strong>${l}</strong><span class="muted small">${d}</span></button>`)}</div>
<div class="row gap">
<div class="grow"><label class="field-label">Workspace</label>
<select class="input" value=${ws} onChange=${(e) => { setWs(e.target.value); setFolder(''); }}>${s.workspaces.map((w) => html`<option value=${w.id}>${w.name}</option>`)}</select></div>
<div class="grow"><label class="field-label">Folder</label>
<select class="input" value=${folder} onChange=${(e) => setFolder(e.target.value)}><option value="">(none)</option>
${s.folders.filter((f) => f.workspace_id === ws).map((f) => html`<option value=${f.id}>${f.name}</option>`)}</select></div>
</div>
<div class="modal-actions"><button type="button" class="btn ghost" onClick=${closeModal}>Cancel</button><button class="btn primary" disabled=${busy}>Create board</button></div>
</form>`;
}
+143
View File
@@ -0,0 +1,143 @@
// Tiny global store: plain object + subscribe. Components call useStore() to re-render on change.
import { useEffect, useState } from '../vendor/preact-htm.mjs';
import { api, onEvent, clientId } from './api.js';
export const state = {
me: null,
users: [],
usersById: {},
workspaces: [],
folders: [],
boards: [],
favorites: [],
unread: 0,
online: [],
boardData: {}, // boardId → full board payload
route: parseRoute(),
toasts: [],
sidebarOpen: false, // mobile drawer
sidebarCollapsed: localGet('wb.sidebarCollapsed') === '1',
currentWorkspace: localGet('wb.workspace'),
theme: localGet('wb.theme') || 'system',
};
const subs = new Set();
let scheduled = false;
export function update(patch) {
if (patch) Object.assign(state, typeof patch === 'function' ? patch(state) : patch);
if (scheduled) return;
scheduled = true;
queueMicrotask(() => { scheduled = false; for (const fn of subs) fn(); });
}
export function useStore() {
const [, set] = useState(0);
useEffect(() => { const fn = () => set((n) => n + 1); subs.add(fn); return () => subs.delete(fn); }, []);
return state;
}
export function localGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
export function localSet(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch { /* private mode */ } }
// ─── Routing ─────────────────────────────────────────────
export function parseRoute(path = location.pathname, search = location.search) {
const p = path.split('/').filter(Boolean);
const q = Object.fromEntries(new URLSearchParams(search));
if (p[0] === 'boards' && p[1]) {
return { name: 'board', boardId: p[1], viewId: p[2] === 'views' ? p[3] : null, itemId: p.includes('items') ? p[p.indexOf('items') + 1] : null, q };
}
if (p[0] === 'workspaces' && p[1]) return { name: 'workspace', workspaceId: p[1], q };
if (['my-work', 'inbox', 'search', 'admin', 'people', 'favorites', 'profile'].includes(p[0])) return { name: p[0], q, tab: p[1] };
return { name: 'home', q };
}
export function navigate(path, { replace = false } = {}) {
if (path === location.pathname + location.search) return;
history[replace ? 'replaceState' : 'pushState']({}, '', path);
update({ route: parseRoute(), sidebarOpen: false });
}
window.addEventListener('popstate', () => update({ route: parseRoute() }));
export function boardPath(boardId, { viewId, itemId } = {}) {
let p = `/boards/${boardId}`;
if (viewId) p += `/views/${viewId}`;
if (itemId) p += `/items/${itemId}`;
return p;
}
// ─── Data loading ────────────────────────────────────────
export async function loadBootstrap() {
const d = await api.get('/api/bootstrap');
setUsers(d.users);
update({ me: d.me, workspaces: d.workspaces, folders: d.folders, boards: d.boards, favorites: d.favorites, unread: d.unread, online: d.online });
if (!state.currentWorkspace || !d.workspaces.some((w) => w.id === state.currentWorkspace)) {
update({ currentWorkspace: d.workspaces[0]?.id || null });
}
}
function setUsers(users) {
update({ users, usersById: Object.fromEntries(users.map((u) => [u.id, u])) });
}
export async function loadTree() {
const d = await api.get('/api/tree');
update({ workspaces: d.workspaces, folders: d.folders, boards: d.boards, favorites: d.favorites });
}
export async function loadUsers() { setUsers(await api.get('/api/users')); }
const inflight = {};
export async function loadBoard(boardId) {
if (inflight[boardId]) return inflight[boardId];
inflight[boardId] = api.get(`/api/boards/${boardId}`)
.then((d) => { state.boardData[boardId] = d; update(); return d; })
.catch((e) => { if (e.status === 404) { state.boardData[boardId] = { missing: true }; update(); } throw e; })
.finally(() => { delete inflight[boardId]; });
return inflight[boardId];
}
// Optimistic local edit of a loaded board, then server call; on failure, reload.
export function mutateBoard(boardId, fn) {
const b = state.boardData[boardId];
if (b && !b.missing) { fn(b); update(); }
}
export async function serverCall(boardId, promise) {
try { return await promise; }
catch (e) { toast(e.message, 'error'); if (boardId) loadBoard(boardId); throw e; }
}
// Live refresh: refetch what's on screen when someone else changes it.
const refetchTimers = {};
onEvent((ev) => {
if (ev.type === 'reconnected') {
if (state.me) { loadTree(); for (const id of Object.keys(state.boardData)) if (isVisible(id)) loadBoard(id); }
return;
}
if (ev.type === 'notification') {
api.get('/api/bootstrap').then((d) => update({ unread: d.unread, online: d.online }));
window.dispatchEvent(new CustomEvent('wb:notifications'));
return;
}
if (ev.origin === clientId) return;
if (ev.type === 'tree') loadTree();
if (ev.type === 'board' && state.boardData[ev.boardId]) {
clearTimeout(refetchTimers[ev.boardId]);
refetchTimers[ev.boardId] = setTimeout(() => {
if (isVisible(ev.boardId)) loadBoard(ev.boardId); else delete state.boardData[ev.boardId];
window.dispatchEvent(new CustomEvent('wb:board-changed', { detail: ev.boardId }));
}, 150);
}
});
function isVisible(boardId) { return state.route.boardId === boardId; }
// ─── Toasts ──────────────────────────────────────────────
let toastId = 0;
export function toast(text, kind = 'info', action) {
const t = { id: ++toastId, text, kind, action };
update({ toasts: [...state.toasts, t] });
setTimeout(() => update({ toasts: state.toasts.filter((x) => x.id !== t.id) }), action ? 6000 : 3500);
}
// ─── Theme ───────────────────────────────────────────────
export function applyTheme(theme = state.theme) {
const dark = theme === 'dark' || theme === 'night' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = theme === 'night' ? 'night' : dark ? 'dark' : 'light';
document.querySelector('meta[name=theme-color]')?.setAttribute('content', dark ? (theme === 'night' ? '#111111' : '#181b34') : '#ffffff');
}
export function setTheme(theme) { localSet('wb.theme', theme); update({ theme }); applyTheme(theme); }
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => applyTheme());
+312
View File
@@ -0,0 +1,312 @@
// Main table view: colored groups, inline-editable cells, drag & drop, column menus,
// subitems, footers, multi-select bulk actions. Becomes a card list on phones.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, navigate, boardPath, mutateBoard, loadBoard } from './store.js';
import { Icon, InlineEdit, menu, openPopover, closePopover, ColorPicker, promptDialog, AvatarStack, textOn, fmtDate, fmtRange, openModal } from './ui.js';
import { Cell, Summary, COLUMN_TYPES, colWidth, labelOf } from './cells.js';
import * as A from './actions.js';
import { ColumnSettingsModal } from './boardModals.js';
const NAME_W = 340;
const selection = new Set();
let dragItem = null;
let dragGroup = null;
function setSel(fn) { fn(selection); update(); }
export function TableView({ board, view, items, subs, matches, hidden, filter, mobile }) {
const boardId = board.board.id;
const cols = board.columns.filter((c) => !hidden.includes(c.id));
const groups = [...board.groups].sort((a, b) => a.position - b.position);
const byGroup = {};
for (const it of items) (byGroup[it.group_id] ||= []).push(it);
const [expanded, setExpanded] = useState({});
const filtering = !!matches || !!filter.sort;
useEffect(() => () => selection.clear(), [boardId]);
if (mobile) return html`<${MobileList} board=${board} groups=${groups} byGroup=${byGroup} subs=${subs} view=${view} />`;
const totalW = NAME_W + cols.reduce((a, c) => a + colWidth(c), 0) + 60;
return html`<div class="table-scroll">
<div class="table" style=${{ minWidth: totalW }}>
${groups.map((g) => {
const gItems = byGroup[g.id] || [];
if (filtering && !gItems.length) return null;
return html`<${Group} key=${g.id} board=${board} group=${g} items=${gItems} cols=${cols} subs=${subs} view=${view}
expanded=${expanded} setExpanded=${setExpanded} totalW=${totalW} filtering=${filtering} />`;
})}
<button class="add-group-btn" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add new group</button>
</div>
${selection.size ? html`<${BulkBar} board=${board} />` : null}
</div>`;
}
function Group({ board, group: g, items, cols, subs, view, expanded, setExpanded, totalW, filtering }) {
const boardId = board.board.id;
const [dropOver, setDropOver] = useState(false);
const allSel = items.length && items.every((i) => selection.has(i.id));
const groupMenu = (e) => menu(e, [
{ label: g.collapsed ? 'Expand group' : 'Collapse group', icon: g.collapsed ? 'chevDown' : 'chevRight', onClick: () => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed }) },
{ label: 'Collapse all groups', icon: 'chevUp', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: true })) },
{ label: 'Expand all groups', icon: 'chevDown', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: false })) },
{ divider: true },
{ label: 'Add group', icon: 'plus', onClick: () => A.addGroup(boardId, { top: false }) },
{ label: 'Rename group', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename group', value: g.title }); if (t) A.patchGroup(boardId, g.id, { title: t }); } },
{ label: 'Change group color', icon: 'gear', keepOpen: true, onClick: (ev) => openPopover(ev.currentTarget, ColorPicker, { value: g.color, onPick: (c) => A.patchGroup(boardId, g.id, { color: c }) }) },
{ label: 'Duplicate group', icon: 'copy', onClick: async () => { await api.post(`/api/groups/${g.id}/duplicate`); loadBoard(boardId); } },
{ label: 'Move group up', icon: 'chevUp', onClick: () => moveGroup(board, g, -1) },
{ label: 'Move group down', icon: 'chevDown', onClick: () => moveGroup(board, g, 1) },
{ label: 'Select all items', icon: 'check', onClick: () => setSel((s) => items.forEach((i) => s.add(i.id))) },
{ divider: true },
{ label: 'Delete group', icon: 'trash', danger: true, onClick: () => A.deleteGroup(boardId, g.id) },
]);
const onGroupDragStart = (e) => { dragGroup = g.id; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', g.id); };
const onDrop = (e) => {
e.preventDefault(); setDropOver(false);
if (dragGroup && dragGroup !== g.id) {
const sorted = [...board.groups].sort((a, b) => a.position - b.position).filter((x) => x.id !== dragGroup);
const idx = sorted.findIndex((x) => x.id === g.id);
A.patchGroup(boardId, dragGroup, { position: A.between(sorted[idx - 1]?.position, g.position) });
dragGroup = null;
} else if (dragItem && !dragItem.parent_id && dragItem.group_id !== g.id) {
const last = items[items.length - 1];
A.moveItem(boardId, dragItem.id, { group_id: g.id, position: (last?.position ?? 0) + 1 });
dragItem = null;
}
};
if (g.collapsed) {
return html`<div class="group collapsed" style=${{ '--g': g.color }} onDragOver=${(e) => { e.preventDefault(); setDropOver(true); }} onDragLeave=${() => setDropOver(false)} onDrop=${onDrop}>
<div class="group-collapsed-row ${dropOver ? 'drop' : ''}" style=${{ width: totalW }}>
<div class="gc-name" style=${{ width: NAME_W }} draggable="true" onDragStart=${onGroupDragStart}>
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: false })}><${Icon} name="chevRight" /></button>
<div><div class="group-title" style=${{ color: g.color }}>${g.title}</div><div class="muted small">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</div></div>
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
</div>
${cols.map((c) => html`<div key=${c.id} class="gc-cell" style=${{ width: colWidth(c) }}><div class="gc-label muted small">${c.title}</div><${Summary} board=${board} col=${c} items=${items} /></div>`)}
</div></div>`;
}
return html`<section class="group" style=${{ '--g': g.color }}>
<div class="group-head">
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: true })} style=${{ color: g.color }}><${Icon} name="chevDown" /></button>
<span class="group-title" style=${{ color: g.color }} draggable="true" onDragStart=${onGroupDragStart}>
<${InlineEdit} value=${g.title} onSave=${(t) => t && A.patchGroup(boardId, g.id, { title: t })} /></span>
<span class="muted small group-count">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</span>
</div>
<div class="group-table ${dropOver ? 'drop' : ''}" onDragOver=${(e) => { if (dragGroup || (dragItem && !dragItem.parent_id)) { e.preventDefault(); setDropOver(true); } }}
onDragLeave=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDropOver(false); }} onDrop=${onDrop}>
<div class="trow thead">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
<span class="row-bar first" />
<label class="row-check"><input type="checkbox" checked=${allSel} onChange=${(e) => setSel((s) => items.forEach((i) => e.target.checked ? s.add(i.id) : s.delete(i.id)))} /></label>
<span class="th-title">${board.board.item_label}</span>
</div>
${cols.map((c) => html`<${ColumnHeader} key=${c.id} board=${board} col=${c} />`)}
<div class="tcell add-col"><button class="icon-btn" onClick=${(e) => addColumnMenu(e, boardId)} title="Add column"><${Icon} name="plus" /></button></div>
</div>
${items.map((it) => html`<${Row} key=${it.id} board=${board} item=${it} cols=${cols} view=${view} group=${g}
subs=${subs[it.id] || []} expanded=${!!expanded[it.id]} toggle=${() => setExpanded({ ...expanded, [it.id]: !expanded[it.id] })} siblings=${items} />`)}
<${AddRow} board=${board} group=${g} cols=${cols} />
<div class="trow tfoot">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}><span class="row-bar last" /></div>
${cols.map((c) => html`<div key=${c.id} class="tcell" style=${{ width: colWidth(c) }}><${Summary} board=${board} col=${c} items=${items} /></div>`)}
</div>
</div>
</section>`;
}
function moveGroup(board, g, dir) {
const sorted = [...board.groups].sort((a, b) => a.position - b.position);
const i = sorted.findIndex((x) => x.id === g.id);
const j = i + dir;
if (j < 0 || j >= sorted.length) return;
const other = sorted[j];
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, other.position) : A.between(other.position, sorted[j + 1]?.position);
A.patchGroup(board.board.id, g.id, { position: pos });
}
export function addColumnMenu(e, boardId, after) {
menu(e, Object.entries(COLUMN_TYPES).map(([type, t]) => ({
label: t.label, icon: t.icon, onClick: () => A.addColumn(boardId, type, { after }),
})), { class: 'col-type-menu' });
}
function ColumnHeader({ board, col }) {
const boardId = board.board.id;
const w = colWidth(col);
const startResize = (e) => {
e.preventDefault(); e.stopPropagation();
const x0 = e.clientX;
const move = (ev) => mutateBoard(boardId, () => { col.width = Math.max(70, Math.min(600, w + ev.clientX - x0)); });
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); A.patchColumn(boardId, col.id, { width: col.width }); };
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
};
const colMenu = (e) => menu(e, [
{ label: 'Settings', icon: 'gear', onClick: () => openModal(ColumnSettingsModal, { boardId, colId: col.id }) },
{ label: 'Rename', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); } },
{ label: 'Sort ascending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'asc' } }); update(); } },
{ label: 'Sort descending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'desc' } }); update(); } },
{ label: 'Add column to the right', icon: 'plus', keepOpen: true, onClick: (ev) => addColumnMenu(ev, boardId, col.id) },
{ label: 'Duplicate column', icon: 'copy', onClick: async () => {
const r = await api.post(`/api/boards/${boardId}/columns`, { type: col.type, title: `${col.title} copy`, settings: col.settings, after: col.id });
for (const it of board.items) if (it.vals[col.id] != null && col.type !== 'files') await api.patch(`/api/items/${it.id}`, { vals: { [r.id]: it.vals[col.id] } });
loadBoard(boardId);
} },
{ label: 'Move left', icon: 'chevLeft', onClick: () => moveCol(board, col, -1) },
{ label: 'Move right', icon: 'chevRight', onClick: () => moveCol(board, col, 1) },
{ divider: true },
{ label: 'Delete column', icon: 'trash', danger: true, onClick: () => A.deleteColumn(boardId, col.id) },
]);
return html`<div class="tcell th" style=${{ width: w }} onDblClick=${async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); }}>
<span class="th-title" title=${col.title}>${col.title}</span>
<button class="icon-btn th-menu" onClick=${colMenu}><${Icon} name="dots" size=${14} /></button>
<span class="col-resize" onPointerDown=${startResize} />
</div>`;
}
function moveCol(board, col, dir) {
const sorted = [...board.columns].sort((a, b) => a.position - b.position);
const i = sorted.findIndex((c) => c.id === col.id);
const j = i + dir;
if (j < 0 || j >= sorted.length) return;
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, sorted[j].position) : A.between(sorted[j].position, sorted[j + 1]?.position);
mutateBoard(board.board.id, (b) => { col.position = pos; b.columns.sort((a, z) => a.position - z.position); });
A.patchColumn(board.board.id, col.id, { position: pos });
}
function Row({ board, item, cols, view, group, subs, expanded, toggle, siblings, isSub }) {
const boardId = board.board.id;
const [dropPos, setDropPos] = useState(null);
const nUpdates = board.updateCounts?.[item.id] || 0;
const open = () => navigate(boardPath(boardId, { viewId: view?.id, itemId: item.id }));
const sel = selection.has(item.id);
const active = state.route.itemId === item.id;
const rowMenu = (e) => menu(e, [
{ label: 'Open', icon: 'expand', onClick: open },
!isSub && { label: 'Add subitem', icon: 'subitem', onClick: async () => { if (!expanded) toggle(); await A.createItem(boardId, { parent_id: item.id, name: 'Subitem' }); } },
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateItems(boardId, [item.id]) },
!isSub && { label: 'Move to group', icon: 'move', keepOpen: true, onClick: (ev) => menu(ev, board.groups.map((g) => ({ label: g.title, onClick: () => A.moveItemsToGroup(boardId, [item.id], g.id) }))) },
!isSub && { label: 'Move to board', icon: 'board', keepOpen: true, onClick: (ev) => menu(ev, state.boards.filter((b) => b.id !== boardId).map((b) => ({ label: b.name, onClick: () => A.moveItemToBoard(boardId, item.id, b.id) }))) },
{ label: 'Copy link', icon: 'link', onClick: () => navigator.clipboard?.writeText(location.origin + boardPath(boardId, { itemId: item.id })) },
{ divider: true },
{ label: 'Archive', icon: 'archive', onClick: () => A.archiveItems(boardId, [item.id]) },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteItems(boardId, [item.id]) },
]);
const onDrop = (e) => {
if (!dragItem || dragItem.id === item.id) return;
if (!!dragItem.parent_id !== !!item.parent_id || (isSub && dragItem.parent_id !== item.parent_id)) return;
e.preventDefault(); e.stopPropagation();
const idx = siblings.findIndex((s) => s.id === item.id);
const pos = dropPos === 'above' ? A.between(siblings[idx - 1]?.position, item.position) : A.between(item.position, siblings[idx + 1]?.position);
A.moveItem(boardId, dragItem.id, isSub ? { position: pos } : { group_id: group.id, position: pos });
dragItem = null; setDropPos(null);
};
return html`
<div class="trow item-row ${sel ? 'selected' : ''} ${active ? 'active' : ''} ${isSub ? 'sub' : ''} ${dropPos ? 'drop-' + dropPos : ''}"
onContextMenu=${rowMenu}
onDragOver=${(e) => { if (dragItem && dragItem.id !== item.id) { e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); setDropPos(e.clientY < r.top + r.height / 2 ? 'above' : 'below'); } }}
onDragLeave=${() => setDropPos(null)} onDrop=${onDrop}>
<div class="tcell name-cell sticky" style=${{ width: NAME_W }} draggable="true"
onDragStart=${(e) => { dragItem = item; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', item.id); }}
onDragEnd=${() => { dragItem = null; }}>
<button class="icon-btn row-menu" onClick=${rowMenu}><${Icon} name="dots" size=${14} /></button>
<span class="row-bar" />
<label class="row-check" onClick=${(e) => e.stopPropagation()}><input type="checkbox" checked=${sel} onChange=${(e) => setSel((s) => e.target.checked ? s.add(item.id) : s.delete(item.id))} /></label>
${isSub ? html`<span class="sub-indent" />` : html`<button class="icon-btn sub-toggle ${subs.length ? 'has' : ''} ${expanded ? 'open' : ''}" onClick=${toggle} title=${subs.length ? `${subs.length} subitems` : 'Add subitem'}>
<${Icon} name=${expanded ? 'chevDown' : 'chevRight'} size=${14} />${subs.length ? html`<span class="sub-count">${subs.length}</span>` : null}</button>`}
<span class="item-name"><${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} /></span>
<button class="icon-btn open-item" onClick=${open} title="Open"><${Icon} name="expand" size=${13} /></button>
<button class="icon-btn updates-btn ${nUpdates ? 'has' : ''}" onClick=${open} title="Updates">
<${Icon} name=${nUpdates ? 'message' : 'messagePlus'} size=${18} />${nUpdates ? html`<span class="badge">${nUpdates}</span>` : null}</button>
</div>
${cols.map((c) => html`<div key=${c.id} class="tcell tc-${c.type}" style=${{ width: colWidth(c) }}><${Cell} board=${board} col=${c} item=${item} /></div>`)}
<div class="tcell add-col" />
</div>
${expanded && !isSub ? html`<div class="subitems">
${subs.sort((a, b) => a.position - b.position).map((s) => html`<${Row} key=${s.id} board=${board} item=${s} cols=${cols} view=${view} group=${group} subs=${[]} isSub=${true} siblings=${subs} />`)}
<${AddRow} board=${board} group=${group} cols=${cols} parent=${item} />
</div>` : null}`;
}
function AddRow({ board, group, cols, parent }) {
const [v, setV] = useState('');
const ref = useRef();
const add = async () => {
const name = v.trim();
if (!name) return;
setV('');
await A.createItem(board.board.id, parent ? { parent_id: parent.id, name } : { group_id: group.id, name });
ref.current?.focus();
};
return html`<div class="trow add-row ${parent ? 'sub' : ''}">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
<span class="row-bar faded" />
<span class="row-check-spacer" />
${parent ? html`<span class="sub-indent" />` : null}
<input ref=${ref} class="add-input" placeholder=${parent ? '+ Add subitem' : `+ Add ${board.board.item_label.toLowerCase()}`} value=${v}
onInput=${(e) => setV(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') add(); if (e.key === 'Escape') { setV(''); e.target.blur(); } }} onBlur=${add} />
</div>
<div class="tcell filler" style=${{ width: cols.reduce((a, c) => a + colWidth(c), 0) }} />
</div>`;
}
function BulkBar({ board }) {
const boardId = board.board.id;
const ids = [...selection].filter((id) => board.items.some((i) => i.id === id));
if (!ids.length) { selection.clear(); return null; }
const done = () => setSel((s) => s.clear());
const statusCols = board.columns.filter((c) => c.type === 'status');
return html`<div class="bulk-bar">
<span class="bulk-count">${ids.length}</span><span class="bulk-label">${ids.length === 1 ? 'Item' : 'Items'} selected</span>
<button class="bulk-act" onClick=${() => { A.duplicateItems(boardId, ids); done(); }}><${Icon} name="copy" /><span>Duplicate</span></button>
${statusCols.length ? html`<button class="bulk-act" onClick=${(e) => menu(e, statusCols.flatMap((c) => [{ label: c.title, disabled: true },
...(c.settings.labels || []).map((l) => ({ label: l.name, onClick: async () => { await api.post('/api/items/bulk', { ids, action: 'set', vals: { [c.id]: l.id } }); loadBoard(boardId); } }))]))}>
<${Icon} name="check" /><span>Set status</span></button>` : null}
<button class="bulk-act" onClick=${(e) => menu(e, board.groups.map((g) => ({ label: g.title, onClick: () => { A.moveItemsToGroup(boardId, ids, g.id); done(); } })))}><${Icon} name="move" /><span>Move to</span></button>
<button class="bulk-act" onClick=${() => { A.archiveItems(boardId, ids); done(); }}><${Icon} name="archive" /><span>Archive</span></button>
<button class="bulk-act danger" onClick=${async () => { if (await A.deleteItems(boardId, ids)) done(); }}><${Icon} name="trash" /><span>Delete</span></button>
<button class="icon-btn bulk-close" onClick=${done}><${Icon} name="x" /></button>
</div>`;
}
// ─── Phone layout: groups of cards ───────────────────────
function MobileList({ board, groups, byGroup, subs, view }) {
const boardId = board.board.id;
const status = board.columns.find((c) => c.type === 'status');
const people = board.columns.find((c) => c.type === 'people');
const date = board.columns.find((c) => c.type === 'date' || c.type === 'timeline');
const [adding, setAdding] = useState(null);
return html`<div class="mobile-list">
${groups.map((g) => {
const items = byGroup[g.id] || [];
return html`<section key=${g.id} class="m-group" style=${{ '--g': g.color }}>
<button class="m-group-head" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed })}>
<${Icon} name=${g.collapsed ? 'chevRight' : 'chevDown'} /><span style=${{ color: g.color }}>${g.title}</span><span class="muted small">${items.length}</span>
</button>
${!g.collapsed ? html`
${items.map((it) => {
const l = status && labelOf(status, it.vals[status.id]);
const dv = date && it.vals[date.id];
const n = (subs[it.id] || []).length;
return html`<button key=${it.id} class="m-card" onClick=${() => navigate(boardPath(boardId, { viewId: view?.id, itemId: it.id }))}>
<div class="m-card-top"><span class="m-name">${it.name}</span>${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${24} />` : null}</div>
<div class="m-card-meta">
${status ? html`<span class="m-status" style=${{ background: l?.color || 'var(--status-empty)', color: l ? textOn(l.color) : 'var(--muted)' }}>${l?.name || status.title}</span>` : null}
${dv ? html`<span class="m-date"><${Icon} name="calendar" size=${13} /> ${date.type === 'date' ? fmtDate(dv) : fmtRange(dv)}</span>` : null}
${n ? html`<span class="muted small"><${Icon} name="subitem" size=${13} /> ${n}</span>` : null}
${board.updateCounts?.[it.id] ? html`<span class="muted small"><${Icon} name="message" size=${13} /> ${board.updateCounts[it.id]}</span>` : null}
</div></button>`;
})}
${adding === g.id ? html`<input class="input m-add-input" placeholder="Name" autofocus
onKeyDown=${async (e) => { if (e.key === 'Enter' && e.target.value.trim()) { const name = e.target.value.trim(); e.target.value = ''; await A.createItem(boardId, { group_id: g.id, name }); } if (e.key === 'Escape') setAdding(null); }}
onBlur=${() => setAdding(null)} />`
: html`<button class="m-add" onClick=${() => setAdding(g.id)}><${Icon} name="plus" /> Add ${board.board.item_label.toLowerCase()}</button>`}
` : null}
</section>`;
})}
<button class="btn ghost m-add-group" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add group</button>
</div>`;
}
+96
View File
@@ -0,0 +1,96 @@
// Timeline (Gantt): bars from a timeline column (or a date column as single days), grouped
// by board group. Drag a bar to move it, drag its right edge to change the end date.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { navigate, boardPath, mutateBoard } from './store.js';
import { Icon, ymd, parseDate, todayStr, Empty, AvatarStack } from './ui.js';
import * as A from './actions.js';
const DAY = 86400000;
const SCALES = { day: 40, week: 18, month: 6 };
export function TimelineView({ board, view, items }) {
const boardId = board.board.id;
const cols = board.columns.filter((c) => c.type === 'timeline' || c.type === 'date');
const col = cols.find((c) => c.id === view.settings?.column) || cols.find((c) => c.type === 'timeline') || cols[0];
const people = board.columns.find((c) => c.type === 'people');
const [scale, setScale] = useState(view.settings?.scale || 'week');
const scroller = useRef();
const [drag, setDrag] = useState(null);
const px = SCALES[scale];
const today = parseDate(todayStr());
const range = (it) => {
const v = it.vals[col?.id];
if (!v) return null;
if (col.type === 'date') { const d = parseDate(String(v).slice(0, 10)); return { from: d, to: d }; }
return { from: parseDate(v.from), to: parseDate(v.to || v.from) };
};
const ranges = items.map(range).filter(Boolean);
const min = new Date(Math.min(today - 14 * DAY, ...ranges.map((r) => r.from))); min.setDate(min.getDate() - 7);
const max = new Date(Math.max(today.getTime() + 60 * DAY, ...ranges.map((r) => r.to.getTime()))); max.setDate(max.getDate() + 14);
const days = Math.round((max - min) / DAY);
const x = (d) => Math.round((d - min) / DAY) * px;
useEffect(() => { if (scroller.current) scroller.current.scrollLeft = Math.max(0, x(today) - 200); }, [scale, col?.id]);
if (!col) return html`<${Empty} icon="timeline" title="Timeline needs a timeline or date column"><button class="btn primary" onClick=${() => A.addColumn(boardId, 'timeline')}>Add a timeline column</button><//>`;
const setting = (patch) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, ...patch }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, ...patch } }); };
const startDrag = (e, it, mode) => {
e.preventDefault(); e.stopPropagation();
const r = range(it); const x0 = e.clientX;
let delta = 0;
const move = (ev) => { delta = Math.round((ev.clientX - x0) / px); setDrag({ id: it.id, mode, delta }); };
const up = () => {
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up);
setDrag(null);
if (!delta) { if (mode === 'move') navigate(boardPath(boardId, { viewId: view.id, itemId: it.id })); return; }
const shift = (d, n) => { const z = new Date(d); z.setDate(z.getDate() + n); return ymd(z); };
if (col.type === 'date') A.setValue(boardId, it.id, col.id, shift(r.from, delta));
else if (mode === 'move') A.setValue(boardId, it.id, col.id, { from: shift(r.from, delta), to: shift(r.to, delta) });
else { const to = new Date(r.to); to.setDate(to.getDate() + delta); A.setValue(boardId, it.id, col.id, { from: ymd(r.from), to: ymd(to < r.from ? r.from : to) }); }
};
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
};
// Header ticks
const ticks = [];
for (let i = 0; i <= days; i++) {
const d = new Date(min); d.setDate(d.getDate() + i);
if (scale === 'day' || (scale === 'week' && d.getDay() === 1) || (scale === 'month' && d.getDate() === 1)) ticks.push(d);
}
const groups = [...board.groups].sort((a, b) => a.position - b.position);
return html`<div class="timeline">
<div class="view-options">
<div class="seg">${Object.keys(SCALES).map((s) => html`<button key=${s} class=${scale === s ? 'on' : ''} onClick=${() => { setScale(s); setting({ scale: s }); }}>${s[0].toUpperCase() + s.slice(1)}s</button>`)}</div>
<button class="btn ghost sm" onClick=${() => { scroller.current.scrollLeft = Math.max(0, x(today) - 200); }}>Today</button>
${cols.length > 1 ? html`<select class="input sm" value=${col.id} onChange=${(e) => setting({ column: e.target.value })}>${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>` : null}
</div>
<div class="tl-body">
<div class="tl-names">
<div class="tl-corner" />
${groups.map((g) => { const gi = items.filter((i) => i.group_id === g.id); return gi.length ? html`<div key=${g.id}>
<div class="tl-group" style=${{ color: g.color }}>${g.title}</div>
${gi.map((it) => html`<div key=${it.id} class="tl-name" onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>${it.name}</div>`)}</div>` : null; })}
</div>
<div class="tl-scroll" ref=${scroller}>
<div class="tl-canvas" style=${{ width: days * px }}>
<div class="tl-ticks">${ticks.map((d) => html`<span key=${d.getTime()} style=${{ left: x(d) }}>${scale === 'month' ? d.toLocaleString([], { month: 'short', year: '2-digit' }) : `${d.toLocaleString([], { month: 'short' })} ${d.getDate()}`}</span>`)}</div>
<div class="tl-today" style=${{ left: x(today) + px / 2 }} />
${groups.map((g) => { const gi = items.filter((i) => i.group_id === g.id); return gi.length ? html`<div key=${g.id}>
<div class="tl-group-row" />
${gi.map((it) => {
const r = range(it);
let left = r ? x(r.from) : 0; let width = r ? Math.max(px, x(r.to) - x(r.from) + px) : 0;
if (drag?.id === it.id) { if (drag.mode === 'move') left += drag.delta * px; else width = Math.max(px, width + drag.delta * px); }
return html`<div key=${it.id} class="tl-row">
${r ? html`<div class="tl-item" style=${{ left, width, background: g.color }} onPointerDown=${(e) => startDrag(e, it, 'move')} title=${it.name}>
<span class="tl-label">${it.name}</span>${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${18} max=${2} />` : null}
${col.type === 'timeline' ? html`<span class="tl-resize" onPointerDown=${(e) => startDrag(e, it, 'resize')} />` : null}
</div>` : html`<button class="tl-set muted small" style=${{ left: x(today) }} onClick=${() => A.setValue(boardId, it.id, col.id, col.type === 'date' ? todayStr() : { from: todayStr(), to: ymd(new Date(today.getTime() + 6 * DAY)) })}>+ Set dates</button>`}
</div>`;
})}</div>` : null; })}
</div>
</div>
</div>
</div>`;
}
+330
View File
@@ -0,0 +1,330 @@
// Shared UI pieces: icons, avatars, popovers, modals, menus, inline editing, formatting.
import { html, useState, useEffect, useRef, useLayoutEffect } from '../vendor/preact-htm.mjs';
import { state, update } from './store.js';
// ─── Icons (24px stroke set) ─────────────────────────────
const P = {
home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10',
work: 'M9 11l3 3 8-8M20 12v7a2 2 0 01-2 2H6a2 2 0 01-2-2V5a2 2 0 012-2h9',
bell: 'M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0',
inbox: 'M22 12h-6l-2 3h-4l-2-3H2M5.5 5.1L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.5-6.9A2 2 0 0016.8 4H7.2a2 2 0 00-1.7 1.1z',
search: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.3-4.3',
plus: 'M12 5v14M5 12h14',
board: 'M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zM3 9h18M9 21V9',
folder: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
folderOpen: 'M6 14l1.5-2.9A2 2 0 019.2 10H20a2 2 0 011.9 2.5l-1.5 6A2 2 0 0118.5 20H4a2 2 0 01-2-2V5a2 2 0 012-2h3.9a2 2 0 011.7.9l.8 1.2a2 2 0 001.7.9H18a2 2 0 012 2v2',
star: 'M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z',
chevDown: 'M6 9l6 6 6-6', chevRight: 'M9 18l6-6-6-6', chevLeft: 'M15 18l-6-6 6-6', chevUp: 'M18 15l-6-6-6 6',
dots: 'M12 13a1 1 0 100-2 1 1 0 000 2zM19 13a1 1 0 100-2 1 1 0 000 2zM5 13a1 1 0 100-2 1 1 0 000 2z',
x: 'M18 6L6 18M6 6l12 12',
check: 'M20 6L9 17l-5-5',
trash: 'M3 6h18M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2',
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z',
copy: 'M20 9h-9a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-9a2 2 0 00-2-2zM5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1',
filter: 'M22 3H2l8 9.5V19l4 2v-8.5z',
sort: 'M3 6h13M3 12h9M3 18h5M17 8v12M14 17l3 3 3-3',
eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12zM12 15a3 3 0 100-6 3 3 0 000 6z',
eyeOff: 'M17.9 17.9A10 10 0 0112 20c-7 0-11-8-11-8a18.5 18.5 0 015.1-5.9M9.9 4.2A9 9 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.2 3.2M1 1l22 22M14.1 14.1a3 3 0 01-4.2-4.2',
person: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z',
people: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.9M16 3.1a4 4 0 010 7.8',
group: 'M4 6h16M4 12h16M4 18h10',
table: 'M3 3h18v18H3zM3 9h18M3 15h18M9 3v18',
kanban: 'M4 3h4v18H4zM10 3h4v12h-4zM16 3h4v8h-4z',
calendar: 'M19 4H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zM16 2v4M8 2v4M3 10h18',
timeline: 'M3 6h10M7 12h12M5 18h8',
chart: 'M18 20V10M12 20V4M6 20v-6',
file: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6',
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 01-8.5-8.5l9.2-9.2a4 4 0 015.7 5.7l-9.2 9.2a2 2 0 01-2.8-2.8l8.5-8.5',
bolt: 'M13 2L3 14h9l-1 8 10-12h-9z',
gear: 'M12 15a3 3 0 100-6 3 3 0 000 6zM19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z',
menu: 'M3 12h18M3 6h18M3 18h18',
logout: 'M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9',
sun: 'M12 17a5 5 0 100-10 5 5 0 000 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
moon: 'M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z',
message: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z',
messagePlus: 'M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2zM12 7v6M9 10h6',
activity: 'M22 12h-4l-3 9L9 3l-3 9H2',
info: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 16v-4M12 8h.01',
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12',
link: 'M10 13a5 5 0 007.5.5l3-3a5 5 0 00-7-7l-1.7 1.7M14 11a5 5 0 00-7.5-.5l-3 3a5 5 0 007 7l1.7-1.7',
thumb: 'M7 22V11M2 13v7a2 2 0 002 2h12.4a2 2 0 002-1.7l1.4-9A2 2 0 0017.8 9H14V4a3 3 0 00-3-3l-4 9',
reply: 'M9 17l-5-5 5-5M20 18v-2a4 4 0 00-4-4H4',
grip: 'M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01',
expand: 'M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7',
archive: 'M21 8v13H3V8M1 3h22v5H1zM10 12h4',
move: 'M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20',
workspace: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
import: 'M12 3v12M7 10l5 5 5-5M5 21h14',
subitem: 'M6 3v12a3 3 0 003 3h9M15 15l3 3-3 3',
pin: 'M12 17v5M9 10.8V5h6v5.8l3 3.2H6z',
refresh: 'M23 4v6h-6M1 20v-6h6M3.5 9a9 9 0 0114.8-3.4L23 10M1 14l4.7 4.4A9 9 0 0020.5 15',
undo: 'M3 7v6h6M21 17a9 9 0 00-15-6.7L3 13',
};
export function Icon({ name, size = 16, fill = false, class: cls = '' }) {
return html`<svg class="icon ${cls}" width=${size} height=${size} viewBox="0 0 24 24" fill=${fill ? 'currentColor' : 'none'}
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d=${P[name] || ''} /></svg>`;
}
// ─── Avatars ─────────────────────────────────────────────
const AV_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#784bd1', '#cab641', '#175a63'];
export function initials(name = '?') {
const p = name.trim().split(/\s+/);
return ((p[0]?.[0] || '') + (p.length > 1 ? p[p.length - 1][0] : '')).toUpperCase() || '?';
}
function hashColor(s = '') { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0; return AV_COLORS[Math.abs(h) % AV_COLORS.length]; }
export function Avatar({ user, size = 28, ring = false }) {
if (!user) return html`<span class="avatar empty" style=${{ width: size, height: size }}><${Icon} name="person" size=${size * 0.6} /></span>`;
const online = state.online.includes(user.id);
return html`<span class="avatar ${ring ? 'ring' : ''}" title=${user.name} style=${{ width: size, height: size, fontSize: size * 0.4, background: user.avatar ? 'transparent' : hashColor(user.email || user.name) }}>
${user.avatar ? html`<img src=${user.avatar} alt="" referrerpolicy="no-referrer" />` : initials(user.name)}
${online && size >= 28 ? html`<i class="online-dot" />` : null}
</span>`;
}
export function AvatarStack({ ids = [], size = 26, max = 3 }) {
const users = ids.map((i) => state.usersById[i]).filter(Boolean);
if (!users.length) return html`<${Avatar} size=${size} />`;
const shown = users.slice(0, users.length > max ? max - 1 : max);
return html`<span class="avatar-stack">
${shown.map((u) => html`<${Avatar} key=${u.id} user=${u} size=${size} />`)}
${users.length > shown.length ? html`<span class="avatar more" style=${{ width: size, height: size, fontSize: size * 0.38 }}>+${users.length - shown.length}</span>` : null}
</span>`;
}
// ─── Popovers & modals (rendered by <Overlays/> in app.js) ──────
export function openPopover(anchor, Component, props = {}, opts = {}) {
const rect = anchor?.getBoundingClientRect ? anchor.getBoundingClientRect() : anchor;
update({ popover: { rect, Component, props, opts, key: Math.random() } });
}
export function closePopover() { if (state.popover) { const cb = state.popover.props?.onClose; update({ popover: null }); cb?.(); } }
export function openModal(Component, props = {}, opts = {}) { update({ modal: { Component, props, opts, key: Math.random() } }); }
export function closeModal() { update({ modal: null }); }
export function PopoverHost() {
const p = state.popover;
const ref = useRef();
const [pos, setPos] = useState(null);
useLayoutEffect(() => {
if (!p || !ref.current) return setPos(null);
const el = ref.current;
const { rect, opts } = p;
const w = el.offsetWidth; const h = el.offsetHeight;
const vw = window.innerWidth; const vh = window.innerHeight;
if (vw < 600 && !opts.inline) return setPos({ sheet: true });
let left = opts.align === 'right' ? rect.right - w : opts.align === 'center' ? rect.left + rect.width / 2 - w / 2 : rect.left;
let top = rect.bottom + 4;
if (top + h > vh - 8) top = Math.max(8, rect.top - h - 4);
left = Math.max(8, Math.min(left, vw - w - 8));
setPos({ left, top });
}, [p?.key]);
useEffect(() => {
if (!p) return;
const onKey = (e) => { if (e.key === 'Escape') closePopover(); };
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target) && !e.target.closest?.('.modal, .popover-keep')) closePopover(); };
const onScroll = (e) => { if (!ref.current?.contains(e.target) && !p.opts.keepOnScroll) closePopover(); };
document.addEventListener('keydown', onKey);
setTimeout(() => document.addEventListener('pointerdown', onDown), 0);
window.addEventListener('resize', closePopover);
document.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onDown);
window.removeEventListener('resize', closePopover); document.removeEventListener('scroll', onScroll, true);
};
}, [p?.key]);
if (!p) return null;
const { Component, props } = p;
const sheet = pos?.sheet;
return html`
${sheet ? html`<div class="sheet-backdrop" />` : null}
<div ref=${ref} class="popover ${sheet ? 'sheet' : ''} ${p.opts.class || ''}" style=${sheet ? {} : { left: pos?.left ?? -9999, top: pos?.top ?? -9999, minWidth: p.opts.matchWidth ? p.rect.width : undefined }}>
<${Component} key=${p.key} ...${props} />
</div>`;
}
export function ModalHost() {
const m = state.modal;
useEffect(() => {
if (!m) return;
const onKey = (e) => { if (e.key === 'Escape' && !state.popover) closeModal(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [m?.key]);
if (!m) return null;
const { Component, props, opts } = m;
return html`<div class="modal-backdrop" onPointerDown=${(e) => { if (e.target === e.currentTarget) closeModal(); }}>
<div class="modal ${opts.class || ''}" role="dialog">
<${Component} key=${m.key} ...${props} />
</div>
</div>`;
}
export function ModalHeader({ title, sub }) {
return html`<div class="modal-head"><div><h2>${title}</h2>${sub ? html`<p class="muted">${sub}</p>` : null}</div>
<button class="icon-btn" onClick=${closeModal} aria-label="Close"><${Icon} name="x" /></button></div>`;
}
// A list menu: [{label, icon, onClick, danger, divider, sub}]
export function Menu({ items, title }) {
return html`<div class="menu">
${title ? html`<div class="menu-title">${title}</div>` : null}
${items.filter(Boolean).map((it, i) => it.divider ? html`<div key=${i} class="menu-divider" />` : html`
<button key=${i} class="menu-item ${it.danger ? 'danger' : ''} ${it.active ? 'active' : ''}" disabled=${it.disabled}
onClick=${(e) => { if (!it.keepOpen) closePopover(); it.onClick?.(e); }}>
${it.icon ? html`<${Icon} name=${it.icon} />` : html`<span class="menu-spacer" />`}
<span>${it.label}</span>
${it.hint ? html`<span class="menu-hint">${it.hint}</span>` : null}
</button>`)}
</div>`;
}
export function menu(e, items, opts = {}) {
e.stopPropagation(); e.preventDefault?.();
openPopover(e.currentTarget, Menu, { items }, opts);
}
// Confirm & prompt as modals returning promises.
export function confirmDialog({ title, body, confirm = 'Delete', danger = true }) {
return new Promise((resolve) => {
const Done = (v) => { closeModal(); resolve(v); };
openModal(() => html`<div class="dialog">
<${ModalHeader} title=${title} />
${body ? html`<p class="dialog-body">${body}</p>` : null}
<div class="modal-actions">
<button class="btn ghost" onClick=${() => Done(false)}>Cancel</button>
<button class="btn ${danger ? 'danger' : 'primary'}" onClick=${() => Done(true)} autofocus>${confirm}</button>
</div></div>`, {}, { class: 'small' });
});
}
export function promptDialog({ title, label, value = '', placeholder = '', confirm = 'Save' }) {
return new Promise((resolve) => {
function Prompt() {
const [v, setV] = useState(value);
const submit = (e) => { e.preventDefault(); if (!v.trim()) return; closeModal(); resolve(v.trim()); };
return html`<form class="dialog" onSubmit=${submit}>
<${ModalHeader} title=${title} />
${label ? html`<label class="field-label">${label}</label>` : null}
<input class="input" value=${v} placeholder=${placeholder} onInput=${(e) => setV(e.target.value)} ref=${(el) => el && setTimeout(() => { el.focus(); el.select(); }, 0)} />
<div class="modal-actions">
<button type="button" class="btn ghost" onClick=${() => { closeModal(); resolve(null); }}>Cancel</button>
<button class="btn primary" disabled=${!v.trim()}>${confirm}</button>
</div></form>`;
}
openModal(Prompt, {}, { class: 'small' });
});
}
// Click-to-edit text. Commits on Enter/blur, cancels on Escape.
export function InlineEdit({ value, onSave, class: cls = '', placeholder = '', multiline = false, autoEdit = false, onDone, title }) {
const [editing, setEditing] = useState(autoEdit);
const [v, setV] = useState(value || '');
useEffect(() => { if (!editing) setV(value || ''); }, [value, editing]);
const commit = () => {
setEditing(false); onDone?.();
const next = v.trim();
if (next !== (value || '') && (next || placeholder === '')) onSave(next);
else setV(value || '');
};
if (!editing) {
return html`<span class="inline-edit ${cls} ${!value ? 'placeholder' : ''}" title=${title || value} onClick=${(e) => { e.stopPropagation(); setEditing(true); }}>${value || placeholder}</span>`;
}
const Tag = multiline ? 'textarea' : 'input';
return html`<${Tag} class="inline-input ${cls}" value=${v} placeholder=${placeholder}
ref=${(el) => { if (el && !el.dataset.f) { el.dataset.f = 1; el.focus(); el.select?.(); } }}
onClick=${(e) => e.stopPropagation()}
onInput=${(e) => setV(e.target.value)} onBlur=${commit}
onKeyDown=${(e) => {
if (e.key === 'Enter' && (!multiline || !e.shiftKey)) { e.preventDefault(); e.target.blur(); }
if (e.key === 'Escape') { setV(value || ''); setEditing(false); onDone?.(); }
}} />`;
}
// ─── Formatting ──────────────────────────────────────────
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function parseDate(s) {
if (!s) return null;
const [d, t] = String(s).split('T');
const [y, m, day] = d.split('-').map(Number);
if (!y || !m || !day) return null;
const dt = new Date(y, m - 1, day);
if (t) { const [hh, mm] = t.split(':').map(Number); dt.setHours(hh || 0, mm || 0); }
return dt;
}
export function ymd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
export function todayStr() { return ymd(new Date()); }
export function fmtDate(s, { year } = {}) {
const d = parseDate(s);
if (!d) return '';
const showYear = year ?? d.getFullYear() !== new Date().getFullYear();
let out = `${MONTHS[d.getMonth()]} ${d.getDate()}${showYear ? `, ${d.getFullYear()}` : ''}`;
if (String(s).includes('T')) out += `, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
return out;
}
export function fmtRange(v) {
if (!v?.from) return '';
const a = parseDate(v.from); const b = parseDate(v.to || v.from);
if (!a || !b) return '';
const sameYear = a.getFullYear() === b.getFullYear() && a.getFullYear() === new Date().getFullYear();
if (v.from === v.to) return fmtDate(v.from);
if (a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear()) return `${MONTHS[a.getMonth()]} ${a.getDate()} - ${b.getDate()}${sameYear ? '' : `, ${a.getFullYear()}`}`;
return `${MONTHS[a.getMonth()]} ${a.getDate()} - ${MONTHS[b.getMonth()]} ${b.getDate()}${sameYear ? '' : `, ${b.getFullYear()}`}`;
}
export function timeAgo(ts) {
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`;
return fmtDate(ymd(new Date(ts)));
}
export function fmtSize(n) {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 ** 2) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 ** 2).toFixed(1)} MB`;
}
export function fileUrl(f, download) { return `/files/${f.id}/${encodeURIComponent(f.name)}${download ? '?download' : ''}`; }
export function isImage(f) { return /^image\//.test(f.mime || '') || /\.(png|jpe?g|gif|webp|svg)$/i.test(f.name); }
// Readable text on a colored background.
export function textOn(hex) {
const c = String(hex || '').replace('#', '');
if (c.length < 6) return '#fff';
const [r, g, b] = [0, 2, 4].map((i) => parseInt(c.slice(i, i + 2), 16));
return (r * 299 + g * 587 + b * 114) / 1000 > 170 ? '#323338' : '#fff';
}
export const isMobile = () => window.matchMedia('(max-width: 768px)').matches;
export function useMobile() {
const [m, setM] = useState(isMobile());
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)');
const fn = () => setM(mq.matches);
mq.addEventListener('change', fn);
return () => mq.removeEventListener('change', fn);
}, []);
return m;
}
// Render text with links and @mentions highlighted.
export function RichText({ text }) {
const parts = String(text || '').split(/(https?:\/\/[^\s<>()]+|@\[[^\]]+\]\([\w-]+\))/g);
return parts.map((p, i) => {
if (/^https?:\/\//.test(p)) return html`<a key=${i} href=${p} target="_blank" rel="noopener">${p}</a>`;
const m = p.match(/^@\[([^\]]+)\]\(([\w-]+)\)$/);
if (m) return html`<span key=${i} class="mention">@${m[1]}</span>`;
return p;
});
}
export const GROUP_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#9cd326', '#784bd1', '#cab641', '#bb3354', '#175a63', '#ff5ac4', '#7f5347', '#c4c4c4', '#333333'];
export const LABEL_COLORS = ['#00c875', '#fdab3d', '#df2f4a', '#579bfc', '#a25ddc', '#c4c4c4', '#037f4c', '#ff642e', '#9cd326', '#ffcb00', '#bb3354', '#784bd1', '#66ccff', '#ff158a', '#7f5347', '#225091', '#175a63', '#cab641', '#ff7575', '#fdbc64', '#333333', '#401694', '#5559df', '#68a1bd'];
export function ColorPicker({ colors = GROUP_COLORS, value, onPick }) {
return html`<div class="color-grid">${colors.map((c) => html`
<button key=${c} class="swatch ${c === value ? 'on' : ''}" style=${{ background: c }} onClick=${() => { onPick(c); closePopover(); }} aria-label=${c} />`)}</div>`;
}
export function Spinner() { return html`<div class="spinner" aria-label="Loading" />`; }
export function Empty({ icon = 'board', title, children }) {
return html`<div class="empty-state"><div class="empty-icon"><${Icon} name=${icon} size=${36} /></div><h3>${title}</h3>${children}</div>`;
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Waters Boards",
"short_name": "Boards",
"description": "Boards, tasks and projects for the Waters Church team",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#181b34",
"theme_color": "#181b34",
"icons": [
{ "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+730
View File
@@ -0,0 +1,730 @@
/* Waters Boards — light / dark / night themes, desktop + phone layouts. */
:root {
--font: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--bg: #f6f7fb; --surface: #ffffff; --surface-2: #f5f6f8; --surface-3: #ecedf5;
--text: #323338; --muted: #676879; --faint: #c3c6d4; --border: #d0d4e4; --border-soft: #e6e9ef;
--primary: #0073ea; --primary-hover: #0060b9; --primary-soft: #cce5ff; --primary-text: #fff;
--rail: #f6f7fb; --nav: #ffffff; --hover: #dcdfec66; --selected: #cce5ff;
--row-hover: #f5f6f8; --row-selected: #e5f4ff;
--status-empty: #c4c4c4; --tl-rest: #333333; --red: #e2445c; --green: #00c875; --orange: #fdab3d;
--shadow: 0 6px 20px rgba(0,0,0,.12); --shadow-lg: 0 15px 50px rgba(0,0,0,.18);
--radius: 8px; --radius-sm: 4px;
--rail-w: 56px; --nav-w: 256px; --row-h: 36px;
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #181b34; --surface: #30324e; --surface-2: #292f4c; --surface-3: #3a3d5c;
--text: #d5d8df; --muted: #9699a6; --faint: #4b4e69; --border: #4b4e69; --border-soft: #3b3e5a;
--primary: #0073ea; --primary-hover: #1f87f5; --primary-soft: #133774;
--rail: #181b34; --nav: #292f4c; --hover: #ffffff14; --selected: #133774;
--row-hover: #3a3d5c; --row-selected: #233a6b;
--status-empty: #797e93; --tl-rest: #5b5e77;
--shadow: 0 6px 20px rgba(0,0,0,.4); --shadow-lg: 0 15px 50px rgba(0,0,0,.5);
color-scheme: dark;
}
:root[data-theme="night"] {
--bg: #111111; --surface: #1a1a1a; --surface-2: #222222; --surface-3: #2c2c2c;
--text: #e1e1e1; --muted: #a0a0a0; --faint: #3c3c3c; --border: #3c3c3c; --border-soft: #2c2c2c;
--primary: #0073ea; --primary-hover: #1f87f5; --primary-soft: #133774;
--rail: #111111; --nav: #1a1a1a; --hover: #ffffff12; --selected: #133774;
--row-hover: #262626; --row-selected: #1d2c47;
--status-empty: #6b6b6b; --tl-rest: #4d4d4d;
--shadow: 0 6px 20px rgba(0,0,0,.6); --shadow-lg: 0 15px 50px rgba(0,0,0,.7);
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { font-family: var(--font); font-size: 14px; color: var(--text); background: var(--bg); -webkit-font-smoothing: antialiased; overflow: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
input, select, textarea { font: inherit; color: inherit; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; }
.icon { flex-shrink: 0; display: block; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.mono { font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
.spacer { flex: 1; }
.grow { flex: 1; min-width: 0; }
.row { display: flex; align-items: center; }
.gap { gap: 8px; }
.wrap { flex-wrap: wrap; }
.pad { padding: 16px; }
.pad-x { padding: 4px 12px; }
.clamp2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.scroll-y { overflow-y: auto; max-height: 60vh; }
.link { color: var(--primary); cursor: pointer; text-align: left; }
.link:hover { text-decoration: underline; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; flex-shrink: 0; }
/* ─── Buttons & inputs ─── */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px; padding: 0 16px; border-radius: var(--radius-sm);
background: var(--surface-2); color: var(--text); font-weight: 500; white-space: nowrap; transition: background .12s; border: 1px solid var(--border); }
.btn:hover { background: var(--hover); }
.btn.primary { background: var(--primary); color: var(--primary-text); border-color: transparent; }
.btn.primary:hover { background: var(--primary-hover); }
.btn.danger { background: var(--red); color: #fff; border-color: transparent; }
.btn.ghost { background: transparent; border-color: transparent; }
.btn.ghost:hover { background: var(--hover); }
.btn.sm { height: 32px; padding: 0 12px; font-size: 14px; }
.btn:disabled { opacity: .5; cursor: default; }
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm); color: var(--muted); flex-shrink: 0; }
.icon-btn:hover { background: var(--hover); color: var(--text); }
.icon-btn.on { color: var(--primary); }
.icon-btn:disabled { opacity: .35; }
.input { height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); width: 100%; outline: none; }
.input:focus { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.input.sm { height: 32px; padding: 0 8px; font-size: 14px; }
textarea.input { height: auto; padding: 8px 12px; resize: vertical; line-height: 1.5; }
select.input[multiple] { height: auto; padding: 4px; }
.field-label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; }
.check-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; }
.chip-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 16px; border: 1px solid var(--border); }
.chip-btn.on { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.warn-note { background: #fdab3d26; border-left: 3px solid var(--orange); padding: 8px 12px; border-radius: 4px; font-size: 13px; }
.error-note { color: var(--red); font-size: 13px; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.seg button { padding: 5px 12px; font-size: 13px; }
.seg button.on { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
.switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch span { position: absolute; inset: 0; background: var(--faint); border-radius: 10px; transition: .2s; cursor: pointer; }
.switch span::after { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; background: #fff; border-radius: 50%; transition: .2s; }
.switch input:checked + span { background: var(--green); }
.switch input:checked + span::after { transform: translateX(16px); }
/* ─── Avatars ─── */
.avatar { position: relative; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; font-weight: 600;
flex-shrink: 0; overflow: visible; letter-spacing: .3px; }
.avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.avatar.empty { background: transparent; color: var(--faint); border: 1px dashed var(--faint); }
.avatar.more { background: var(--surface-3); color: var(--text); font-weight: 600; }
.avatar .online-dot { position: absolute; right: -1px; bottom: -1px; width: 9px; height: 9px; background: var(--green); border: 2px solid var(--surface); border-radius: 50%; }
.avatar-stack { display: inline-flex; align-items: center; }
.avatar-stack .avatar + .avatar { margin-left: -8px; box-shadow: 0 0 0 2px var(--surface); }
/* ─── Logo / splash / sign-in ─── */
.logo-mark { display: inline-flex; gap: 3px; align-items: flex-start; height: 22px; }
.logo-mark i { width: 6px; border-radius: 3px; display: block; }
.logo-mark i:nth-child(1) { height: 22px; background: #579bfc; }
.logo-mark i:nth-child(2) { height: 16px; background: #00c875; }
.logo-mark i:nth-child(3) { height: 11px; background: #fdab3d; }
.logo-mark.big { height: 56px; gap: 8px; }
.logo-mark.big i { width: 16px; border-radius: 8px; }
.logo-mark.big i:nth-child(1) { height: 56px; } .logo-mark.big i:nth-child(2) { height: 41px; } .logo-mark.big i:nth-child(3) { height: 28px; }
.splash { height: 100vh; display: grid; place-items: center; }
.signin { min-height: 100vh; display: grid; place-items: center; padding: 16px; overflow: auto; background: radial-gradient(circle at 20% 10%, #579bfc22, transparent 50%), radial-gradient(circle at 80% 90%, #00c87522, transparent 50%), var(--bg); }
.signin-card { width: 100%; max-width: 400px; background: var(--surface); border-radius: 16px; padding: 40px 32px; text-align: center; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; align-items: center; gap: 14px; }
.signin-card h1 { font-size: 28px; }
.gbtn { min-height: 44px; }
.signin-form { display: flex; flex-direction: column; gap: 10px; width: 100%; }
/* ─── Shell ─── */
.app { display: flex; height: 100vh; height: 100dvh; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--surface); margin: 8px 8px 0 0; border-radius: 12px 12px 0 0; overflow: hidden; position: relative; }
.sidebar { display: flex; flex-shrink: 0; position: relative; }
.rail { width: var(--rail-w); background: var(--rail); display: flex; flex-direction: column; align-items: center; padding: 12px 0; gap: 4px; }
.logo { width: 40px; height: 40px; display: grid; place-items: center; margin-bottom: 10px; border-radius: 8px; }
.logo:hover { background: var(--hover); }
.rail-btn { position: relative; width: 44px; height: 44px; border-radius: 8px; display: grid; place-items: center; color: var(--muted); }
.rail-btn:hover { background: var(--hover); color: var(--text); }
.rail-btn.active { background: var(--selected); color: var(--primary); }
.rail-label { display: none; }
.rail-badge { position: absolute; top: 3px; right: 2px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--red); color: #fff; font-size: 11px; font-weight: 600; display: grid; place-items: center; border: 2px solid var(--rail); }
.rail-me { margin-top: 8px; border-radius: 50%; }
.nav-panel { width: var(--nav-w); background: var(--nav); margin: 8px 8px 0 0; border-radius: 12px 12px 0 0; display: flex; flex-direction: column; min-height: 0; }
.nav-collapsed .nav-panel { display: none; }
.expand-btn { position: absolute; left: calc(var(--rail-w) - 12px); top: 70px; width: 24px; height: 24px; border-radius: 50%; background: var(--surface); box-shadow: var(--shadow); display: grid; place-items: center; z-index: 5; }
.nav-head { display: flex; align-items: center; gap: 2px; padding: 12px 8px 8px 12px; }
.ws-picker { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-weight: 600; }
.ws-picker:hover { background: var(--hover); }
.ws-icon { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; color: #fff; font-weight: 700; flex-shrink: 0; }
.nav-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
.nav-search { display: flex; align-items: center; gap: 6px; margin: 0 12px 8px; padding: 0 10px; height: 32px; border: 1px solid var(--border); border-radius: 4px; color: var(--muted); }
.nav-search input { border: 0; background: none; outline: none; flex: 1; min-width: 0; color: var(--text); }
.nav-scroll { overflow-y: auto; flex: 1; padding: 0 8px 24px; }
.nav-section { margin-top: 8px; }
.nav-section-title { display: flex; align-items: center; gap: 6px; padding: 6px 8px; font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; }
.nav-board, .nav-folder-row { display: flex; align-items: center; gap: 8px; height: 34px; padding-right: 4px; border-radius: 6px; cursor: pointer; color: var(--text); }
.nav-board:hover, .nav-folder-row:hover { background: var(--hover); }
.nav-board.active { background: var(--selected); }
.nav-board .icon, .nav-folder-row .icon { color: var(--muted); }
.nav-more { opacity: 0; width: 24px; height: 24px; }
.nav-board:hover .nav-more, .nav-folder-row:hover .nav-more { opacity: 1; }
.nav-empty { display: flex; align-items: center; gap: 6px; padding: 8px; color: var(--primary); }
/* ─── Popovers / menus / modals / toasts ─── */
.popover { position: fixed; z-index: 1000; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-lg); border: 1px solid var(--border-soft); max-height: min(560px, 80vh); overflow: auto; }
.popover.sheet { left: 0; right: 0; bottom: 0; top: auto; border-radius: 16px 16px 0 0; max-height: 80vh; padding-bottom: env(safe-area-inset-bottom); }
.sheet-backdrop { position: fixed; inset: 0; background: #0006; z-index: 999; }
.menu { padding: 8px; min-width: 210px; }
.menu-title { padding: 6px 10px; font-size: 12px; color: var(--muted); }
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border-radius: 4px; text-align: left; white-space: nowrap; }
.menu-item:hover:not(:disabled) { background: var(--hover); }
.menu-item.danger { color: var(--red); }
.menu-item.active { color: var(--primary); font-weight: 600; }
.menu-item:disabled { color: var(--muted); cursor: default; font-size: 12px; }
.menu-item .icon { color: var(--muted); }
.menu-item.danger .icon { color: var(--red); }
.menu-spacer { width: 16px; }
.menu-hint { margin-left: auto; padding-left: 16px; color: var(--muted); font-size: 12px; }
.menu-divider { height: 1px; background: var(--border-soft); margin: 6px 0; }
.col-type-menu .menu { display: grid; grid-template-columns: 1fr 1fr; min-width: 360px; }
.modal-backdrop { position: fixed; inset: 0; background: #0009; z-index: 900; display: grid; place-items: center; padding: 16px; animation: fade .12s; }
.modal { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-lg); width: 100%; max-width: 560px; max-height: 90vh; overflow: auto; animation: pop .15s; }
.modal.small { max-width: 420px; }
.modal.wide { max-width: 760px; }
.dialog { padding: 24px; }
.dialog-body { color: var(--muted); line-height: 1.5; }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.modal-head h2 { font-size: 22px; }
.modal-head p { margin: 4px 0 0; }
.modal-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 16px; }
.toasts { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 2000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
.toast { background: #323338; color: #fff; padding: 10px 18px; border-radius: 6px; box-shadow: var(--shadow); pointer-events: auto; display: flex; gap: 12px; animation: pop .15s; }
.toast.error { background: var(--red); }
.toast .link { color: #9fd0ff; }
@keyframes fade { from { opacity: 0; } }
@keyframes pop { from { opacity: 0; transform: scale(.97) translateY(4px); } }
.spinner { width: 28px; height: 28px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; margin: 24px auto; }
.row .spinner { width: 16px; height: 16px; border-width: 2px; margin: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.page-loading { flex: 1; display: grid; place-items: center; }
.empty-state { text-align: center; padding: 60px 20px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.empty-icon { width: 72px; height: 72px; border-radius: 50%; background: var(--surface-2); display: grid; place-items: center; color: var(--muted); }
.pop-pad { padding: 16px; min-width: 260px; max-width: 640px; }
.pop-title { font-weight: 600; margin-bottom: 12px; display: flex; gap: 8px; align-items: baseline; }
/* ─── Inline edit ─── */
.inline-edit { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 4px; border: 1px solid transparent; border-radius: 3px; cursor: text; vertical-align: middle; }
.inline-edit:hover { border-color: var(--border); }
.inline-edit.placeholder { color: var(--muted); }
.inline-input { width: 100%; padding: 2px 4px; border: 1px solid var(--primary); border-radius: 3px; outline: none; background: var(--surface); min-width: 60px; }
/* ─── Board header / tabs / toolbar ─── */
.board-page { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.board-header { padding: 18px 28px 0; }
.board-title-row { display: flex; align-items: center; gap: 8px; }
.board-title { font-size: 24px; font-weight: 600; min-width: 0; }
.board-title .inline-edit { padding: 2px 6px; }
.icon-btn.star.on { color: #ffcb00; }
.board-desc { color: var(--muted); margin: 4px 6px 0; }
.header-people { margin: 0 6px; }
.view-tabs { display: flex; gap: 2px; margin-top: 12px; border-bottom: 1px solid var(--border-soft); overflow-x: auto; scrollbar-width: none; }
.view-tab { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 6px 6px 0 0; position: relative; color: var(--text); white-space: nowrap; }
.view-tab:hover { background: var(--hover); }
.view-tab.active::after { content: ''; position: absolute; left: 6px; right: 6px; bottom: -1px; height: 2px; background: var(--primary); }
.view-tab .icon { color: var(--muted); }
.tab-dots { display: inline-flex; color: var(--muted); }
.toolbar { display: flex; align-items: center; gap: 4px; padding: 12px 28px; flex-wrap: wrap; }
.split-btn { display: inline-flex; margin-right: 8px; }
.split-btn .btn:first-child { border-radius: 4px 0 0 4px; }
.split-btn .caret { border-radius: 0 4px 4px 0; padding: 0 8px; border-left: 1px solid #ffffff40; }
.tb-btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border-radius: 4px; color: var(--text); }
.tb-btn:hover { background: var(--hover); }
.tb-btn.on { background: var(--primary-soft); }
.tb-btn.subtle { color: var(--muted); font-size: 13px; }
.tb-search { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 8px; border: 1px solid var(--primary); border-radius: 4px; color: var(--muted); }
.tb-search input { border: 0; outline: none; background: none; width: 180px; color: var(--text); }
.tb-search.wide { border-color: var(--border); width: 100%; max-width: 420px; margin-bottom: 16px; }
.tb-search.wide input { width: 100%; }
.board-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.view-options { display: flex; align-items: center; gap: 8px; padding: 0 0 12px; flex-wrap: wrap; }
.view-options .input { width: auto; }
/* filter / person / sort / hide popovers */
.avatar-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.avatar-toggle { border-radius: 50%; padding: 2px; border: 2px solid transparent; }
.avatar-toggle.on { border-color: var(--primary); }
.filter-rule { display: grid; grid-template-columns: 44px 1fr 1fr 1fr 30px; gap: 6px; align-items: center; margin-bottom: 8px; min-width: 560px; }
.quick-filters { display: flex; gap: 24px; margin-top: 12px; border-top: 1px solid var(--border-soft); padding-top: 12px; }
.qf-col { min-width: 160px; }
.qf-title { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.qf-opt { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 6px; border-radius: 4px; text-align: left; }
.qf-opt:hover { background: var(--hover); }
.qf-opt i { width: 14px; height: 14px; border-radius: 3px; }
.qf-opt .muted { margin-left: auto; }
.hide-cols .check-row { padding: 5px 0; }
.type-dot { width: 20px; height: 20px; border-radius: 4px; display: inline-grid; place-items: center; color: #fff; flex-shrink: 0; }
/* ─── Table ─── */
.table-scroll { flex: 1; overflow: auto; padding: 0 28px 120px; position: relative; }
.table { display: flex; flex-direction: column; gap: 28px; padding-top: 4px; }
.group { --g: #579bfc; }
.group-head { display: flex; align-items: center; gap: 2px; height: 40px; position: sticky; left: 0; width: max-content; }
.group-menu-btn { opacity: 0; margin-left: -34px; }
.group-head:hover .group-menu-btn, .gc-name:hover .group-menu-btn { opacity: 1; }
.group-title { font-size: 18px; font-weight: 500; }
.group-title .inline-edit { padding: 2px 6px; }
.group-count { margin-left: 8px; opacity: 0; }
.group-head:hover .group-count { opacity: 1; }
.group-table.drop { outline: 2px dashed var(--primary); outline-offset: 2px; border-radius: 6px; }
.trow { display: flex; min-height: var(--row-h); }
.tcell { flex-shrink: 0; border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; position: relative; min-width: 0; background: var(--surface); }
.thead .tcell { border-top: 1px solid var(--border-soft); height: 38px; }
.th { padding: 0 24px; font-size: 14px; color: var(--muted); }
.th-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.th-menu { position: absolute; right: 2px; opacity: 0; width: 22px; height: 22px; }
.th:hover .th-menu { opacity: 1; }
.th:hover { background: var(--row-hover); }
.col-resize { position: absolute; right: -3px; top: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 2; }
.col-resize:hover { background: var(--primary); }
.add-col { width: 60px; border-right: 0; justify-content: flex-start; padding-left: 8px; }
.thead .add-col { border-top: 1px solid var(--border-soft); }
.name-cell { justify-content: flex-start; gap: 4px; padding-right: 6px; border-left: 0; }
.sticky { position: sticky; left: 0; z-index: 3; }
.thead .name-cell { padding-left: 0; }
.thead .name-cell .th-title { color: var(--muted); flex: 1; text-align: center; }
.row-bar { width: 6px; align-self: stretch; background: var(--g); flex-shrink: 0; }
.row-bar.first { border-radius: 6px 0 0 0; }
.row-bar.last { border-radius: 0 0 0 6px; }
.row-bar.faded { opacity: .5; }
.row-check, .row-check-spacer { width: 36px; display: grid; place-items: center; align-self: stretch; border-right: 1px solid var(--border-soft); flex-shrink: 0; }
.row-check input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; }
.row-menu { position: absolute; left: -32px; opacity: 0; }
.item-row:hover .row-menu { opacity: 1; }
.item-row:hover .tcell { background: var(--row-hover); }
.item-row.selected .tcell, .item-row.active .tcell { background: var(--row-selected); }
.item-row.drop-above { box-shadow: inset 0 2px 0 var(--primary); }
.item-row.drop-below { box-shadow: inset 0 -2px 0 var(--primary); }
.item-name { flex: 1; min-width: 0; padding-left: 6px; display: flex; }
.item-name .inline-edit { padding: 3px 6px; }
.open-item { opacity: 0; width: 24px; height: 24px; }
.item-row:hover .open-item { opacity: 1; }
.updates-btn { position: relative; color: var(--faint); }
.updates-btn.has { color: var(--primary); }
.updates-btn .badge { position: absolute; bottom: 1px; right: 0; background: var(--primary); color: #fff; font-size: 10px; min-width: 15px; height: 15px; border-radius: 8px; display: grid; place-items: center; padding: 0 3px; font-weight: 600; }
.sub-toggle { width: 34px; height: 26px; gap: 1px; color: var(--faint); }
.sub-toggle.has { color: var(--muted); }
.sub-toggle.open { color: var(--primary); }
.sub-count { font-size: 11px; }
.item-row:not(:hover) .sub-toggle:not(.has):not(.open) { opacity: 0; }
.subitems { margin-left: 46px; border-left: 2px solid var(--g); }
.item-row.sub .name-cell, .add-row.sub .name-cell { width: calc(340px - 46px) !important; }
.sub-indent { width: 12px; }
.add-row .tcell { background: var(--surface); }
.add-row .filler { border-right: 0; }
.add-input { border: 1px solid transparent; outline: none; background: none; padding: 4px 6px; flex: 1; border-radius: 3px; height: 28px; margin-left: 8px; color: var(--text); }
.add-input:hover { border-color: var(--border); }
.add-input:focus { border-color: var(--primary); }
.tfoot .tcell { border-bottom: 0; border-right: 0; background: transparent; height: 38px; }
.tfoot .tcell:not(.name-cell) { border: 1px solid var(--border-soft); border-top: 0; border-left: 0; }
.tfoot .name-cell { background: var(--surface); }
.add-group-btn { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; width: max-content; position: sticky; left: 0; }
.add-group-btn:hover { background: var(--hover); }
.group-collapsed-row { display: flex; border: 1px solid var(--border-soft); border-left: 6px solid var(--g); border-radius: 6px; min-height: 60px; }
.group-collapsed-row.drop { outline: 2px dashed var(--primary); }
.gc-name { display: flex; align-items: center; gap: 6px; padding: 0 8px 0 40px; position: sticky; left: 0; background: var(--surface); z-index: 2; border-right: 1px solid var(--border-soft); }
.gc-name .group-menu-btn { margin-left: auto; }
.gc-cell { border-right: 1px solid var(--border-soft); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 6px 0; }
/* cells */
.cell-status { width: 100%; height: 100%; min-height: var(--row-h); color: #fff; font-weight: 500; position: relative; padding: 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: filter .1s; }
.cell-status:hover { filter: brightness(1.08); }
.cell-status .fold { position: absolute; right: 0; top: 0; border-style: solid; border-width: 0 10px 10px 0; border-color: transparent var(--surface) transparent transparent; opacity: 0; transition: opacity .1s; }
.cell-status:hover .fold { opacity: 1; }
.cell-people, .cell-date, .cell-timeline, .cell-files, .cell-text.long, .cell-chips { width: 100%; height: 100%; min-height: var(--row-h); display: flex; align-items: center; justify-content: center; padding: 0 6px; gap: 4px; }
.cell-date { gap: 6px; }
.cell-date.overdue { color: var(--red); }
.cell-date .warn { width: 16px; height: 16px; border-radius: 50%; background: var(--red); color: #fff; font-size: 11px; font-weight: 700; display: grid; place-items: center; }
.today-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); }
.ghost-icon { color: var(--faint); opacity: 0; }
.tcell:hover .ghost-icon { opacity: 1; }
.tl-bar { display: flex; align-items: center; justify-content: center; height: 24px; width: calc(100% - 12px); border-radius: 12px; color: #fff; font-size: 13px; overflow: hidden; white-space: nowrap; }
.tl-bar.empty { background: var(--status-empty); }
.tl-bar.summary-range { background: var(--tl-rest); font-size: 12px; height: 22px; }
.cell-number { width: 100%; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 0 6px; }
.cell-number .inline-edit, .cell-number .inline-input { text-align: center; min-width: 40px; }
.cell-number .inline-edit { width: 100%; min-height: 26px; }
.unit { color: var(--muted); font-size: 12px; }
.cell-text { width: 100%; padding: 0 6px; display: flex; align-items: center; }
.cell-text .inline-edit { width: 100%; min-height: 26px; text-align: center; }
.cell-text.long { justify-content: flex-start; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; display: block; line-height: var(--row-h); text-align: left; padding: 0 10px; }
.cell-check { display: grid; place-items: center; width: 100%; height: 100%; cursor: pointer; }
.cell-check input { width: 18px; height: 18px; accent-color: var(--green); cursor: pointer; }
.cell-check span { display: none; }
.cell-rating { display: flex; gap: 1px; }
.cell-rating button { color: var(--faint); padding: 2px; }
.cell-rating button.on { color: #ffcb00; }
.cell-rating:hover button { color: #ffcb0080; }
.cell-rating button:hover ~ button { color: var(--faint); }
.cell-chips { overflow: hidden; flex-wrap: nowrap; justify-content: flex-start; }
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: var(--surface-3); font-size: 13px; white-space: nowrap; }
.chip.tag { color: var(--primary); background: var(--primary-soft); }
.chip button { color: var(--muted); display: inline-flex; }
.chip.person { padding-left: 2px; }
.cell-link { width: 100%; display: flex; align-items: center; padding: 0 8px; gap: 4px; overflow: hidden; }
.cell-link a { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-edit-btn { color: var(--faint); opacity: 0; margin-left: auto; padding: 4px; }
.tcell:hover .cell-edit-btn, .info-value:hover .cell-edit-btn { opacity: 1; }
.cell-files img { width: 26px; height: 26px; object-fit: cover; border-radius: 3px; }
.file-ext { display: inline-grid; place-items: center; min-width: 26px; height: 26px; padding: 0 3px; border-radius: 3px; background: var(--primary-soft); color: var(--primary); font-size: 9px; font-weight: 700; }
.file-ext.big { width: 100%; height: 100%; font-size: 20px; border-radius: 0; }
.file-more { font-size: 12px; color: var(--muted); }
.cell-meta { display: flex; align-items: center; gap: 6px; font-size: 13px; padding: 0 6px; overflow: hidden; white-space: nowrap; }
.summary { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 6px; }
.battery { display: flex; width: 100%; height: 22px; overflow: hidden; border-radius: 2px; }
.battery span { display: block; height: 100%; }
/* bulk bar */
.bulk-bar { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--surface); box-shadow: var(--shadow-lg); border-radius: 8px; display: flex; align-items: center; z-index: 50; border: 1px solid var(--border-soft); overflow: hidden; animation: pop .15s; }
.bulk-count { background: var(--primary); color: #fff; font-size: 24px; font-weight: 300; width: 64px; align-self: stretch; display: grid; place-items: center; }
.bulk-label { padding: 0 20px; font-size: 16px; white-space: nowrap; }
.bulk-act { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 14px; font-size: 12px; color: var(--muted); }
.bulk-act:hover { color: var(--primary); }
.bulk-act.danger:hover { color: var(--red); }
.bulk-close { margin: 0 12px; border-left: 1px solid var(--border-soft); border-radius: 0; height: 64px; width: 48px; }
/* pickers */
.status-picker { padding: 12px; width: 240px; }
.status-grid { display: flex; flex-direction: column; gap: 6px; }
.status-opt { height: 32px; border-radius: 2px; font-weight: 500; padding: 0 8px; }
.status-opt:hover { filter: brightness(1.08); }
.status-opt.blank { border: 1px dashed var(--border); color: var(--muted); }
.label-editor { padding: 12px; display: flex; flex-direction: column; gap: 6px; min-width: 280px; }
.label-row { display: flex; align-items: center; gap: 6px; }
.label-row .input { flex: 1; }
.swatch { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; border: 2px solid transparent; }
.swatch.on { border-color: var(--text); }
.swatch-input { width: 30px; height: 30px; padding: 0; border: 0; background: none; cursor: pointer; flex-shrink: 0; }
.done-toggle { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.label-preview { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.label-preview span { padding: 4px 10px; border-radius: 3px; font-size: 13px; }
.color-grid { display: grid; grid-template-columns: repeat(6, 28px); gap: 6px; padding: 12px; }
.color-grid .swatch { width: 28px; height: 28px; }
.picker { padding: 10px; width: 300px; display: flex; flex-direction: column; gap: 8px; }
.picker-list { max-height: 280px; overflow-y: auto; display: flex; flex-direction: column; }
.picker-section { font-size: 12px; color: var(--muted); padding: 4px 6px; }
.picker-row { display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 4px; text-align: left; }
.picker-row:hover { background: var(--hover); }
.picker-row .small { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.check-box { width: 18px; height: 18px; border: 1px solid var(--border); border-radius: 3px; display: grid; place-items: center; color: transparent; flex-shrink: 0; }
.check-box.on { background: var(--primary); border-color: var(--primary); color: #fff; }
.chips-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
.datepicker { padding: 12px; width: 280px; }
.dp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.dp-dow { text-align: center; font-size: 11px; color: var(--muted); padding: 4px 0; }
.dp-day { height: 34px; border-radius: 50%; font-size: 13px; }
.dp-day:hover { background: var(--hover); }
.dp-day.out { color: var(--faint); }
.dp-day.today { color: var(--primary); font-weight: 700; }
.dp-day.inrange { background: var(--primary-soft); border-radius: 0; }
.dp-day.sel { background: var(--primary); color: #fff; border-radius: 50%; }
.dp-foot { display: flex; gap: 4px; margin-top: 8px; border-top: 1px solid var(--border-soft); padding-top: 8px; }
.dp-time { display: flex; gap: 6px; margin-top: 8px; }
.longtext-editor, .form-pop { padding: 12px; width: 340px; }
.longtext-editor textarea { width: 100%; }
.files-pop { padding: 12px; width: 340px; display: flex; flex-direction: column; gap: 10px; }
.files-list { display: flex; flex-direction: column; gap: 4px; max-height: 300px; overflow: auto; }
.file-row { display: flex; align-items: center; gap: 8px; }
.file-row img { width: 32px; height: 32px; object-fit: cover; border-radius: 3px; }
.file-row > a:first-of-type { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ─── Item panel ─── */
.panel-backdrop { position: fixed; inset: 0; z-index: 80; background: #0003; animation: fade .15s; }
.item-panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(680px, 92vw); background: var(--surface); box-shadow: var(--shadow-lg); z-index: 90; display: flex; flex-direction: column; animation: slide .2s ease-out; }
@keyframes slide { from { transform: translateX(40px); opacity: 0; } }
.panel-head { display: flex; align-items: center; gap: 8px; padding: 12px 16px 0; }
.crumbs { flex: 1; display: flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; white-space: nowrap; }
.panel-title { font-size: 22px; padding: 6px 22px 8px; }
.panel-title .inline-edit { white-space: normal; padding: 2px 4px; }
.panel-tabs { display: flex; gap: 4px; padding: 0 22px; border-bottom: 1px solid var(--border-soft); overflow-x: auto; scrollbar-width: none; }
.panel-tabs button { padding: 10px 12px; position: relative; white-space: nowrap; }
.panel-tabs button.active { font-weight: 600; }
.panel-tabs button.active::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--primary); }
.panel-body { flex: 1; overflow-y: auto; padding: 20px 22px 40px; background: var(--surface-2); }
.updates { display: flex; flex-direction: column; gap: 16px; }
.composer { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.composer:focus-within { border-color: var(--primary); }
.composer textarea { width: 100%; border: 0; outline: none; background: none; resize: vertical; padding: 12px 14px; min-height: 60px; line-height: 1.5; }
.composer.compact textarea { min-height: 40px; }
.composer-actions { display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-top: 1px solid var(--border-soft); }
.composer-files { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 12px 8px; }
.mention-pop { position: absolute; left: 12px; top: 100%; z-index: 5; background: var(--surface); border-radius: 6px; box-shadow: var(--shadow); padding: 4px; min-width: 220px; }
.mention-pop button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border-radius: 4px; text-align: left; }
.mention-pop button:hover, .mention-pop button:first-child { background: var(--hover); }
.update { background: var(--surface); border-radius: 8px; border: 1px solid var(--border-soft); padding: 14px 16px 0; }
.update.reply { border: 0; padding: 10px 0 0; background: none; }
.update-head { display: flex; align-items: center; gap: 10px; }
.update-meta { flex: 1; display: flex; flex-direction: column; }
.update-body { padding: 10px 0; white-space: pre-wrap; word-break: break-word; line-height: 1.55; }
.mention { color: var(--primary); background: var(--primary-soft); padding: 0 3px; border-radius: 3px; }
.update-files { display: flex; flex-wrap: wrap; gap: 8px; padding-bottom: 10px; }
.update-files img { max-width: 220px; max-height: 160px; border-radius: 6px; display: block; }
.file-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); }
.update-actions { display: flex; border-top: 1px solid var(--border-soft); margin: 0 -16px; }
.update-actions button { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 9px; color: var(--muted); }
.update-actions button:hover { background: var(--hover); }
.update-actions button.on { color: var(--primary); }
.update.reply .update-actions { margin: 0; border: 0; justify-content: flex-start; }
.update.reply .update-actions button { flex: 0; padding: 4px 8px; }
.replies { margin: 0 -16px; padding: 0 16px 12px 56px; background: var(--surface-2); border-radius: 0 0 8px 8px; }
.reply-open { width: 100%; text-align: left; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); background: var(--surface); margin-top: 10px; }
.empty-updates { text-align: center; padding: 40px 20px; color: var(--muted); }
.empty-updates h3 { color: var(--text); margin: 10px 0 4px; }
.info-grid { background: var(--surface); border-radius: 8px; border: 1px solid var(--border-soft); }
.info-row { display: grid; grid-template-columns: 180px 1fr; border-bottom: 1px solid var(--border-soft); min-height: 44px; }
.info-row:last-child { border-bottom: 0; }
.info-label { display: flex; align-items: center; gap: 8px; padding: 0 14px; color: var(--muted); border-right: 1px solid var(--border-soft); }
.info-value { display: flex; align-items: center; justify-content: flex-start; min-width: 0; }
.info-value > * { min-height: 44px; }
.info-value .cell-text .inline-edit, .info-value .cell-number .inline-edit { text-align: left; }
.info-value.tc-status .cell-status { min-height: 44px; }
.info-subitems { background: var(--surface); border-radius: 8px; border: 1px solid var(--border-soft); padding: 14px; margin-top: 16px; display: flex; flex-direction: column; gap: 6px; }
.sub-line { display: flex; align-items: center; gap: 8px; min-height: 34px; }
.sub-line .link { flex: 1; }
.sub-status { width: 130px; height: 32px; }
.sub-status .cell-status { min-height: 32px; border-radius: 3px; }
.info-foot { margin-top: 14px; }
.files-tab { display: flex; flex-direction: column; gap: 16px; }
.dropzone { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px; border: 2px dashed var(--border); border-radius: 8px; color: var(--muted); cursor: pointer; background: var(--surface); width: 100%; }
.dropzone:hover { border-color: var(--primary); color: var(--primary); }
.file-grid, .file-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }
.file-gallery { padding: 0 28px 40px; overflow: auto; }
.file-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.file-thumb { height: 120px; display: grid; place-items: center; background: var(--surface-3); }
.file-thumb img { width: 100%; height: 100%; object-fit: cover; }
.file-info { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.file-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-actions { display: flex; justify-content: flex-end; padding: 0 4px 4px; }
.activity { display: flex; flex-direction: column; }
.act-row { display: grid; grid-template-columns: 50px 28px minmax(90px, 160px) 1fr; align-items: center; gap: 8px; padding: 8px 4px; border-bottom: 1px solid var(--border-soft); font-size: 13px; }
.act-who, .act-item { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.act-col { color: var(--muted); }
.act-from { text-decoration: line-through; color: var(--muted); }
.act-to { font-weight: 500; }
/* ─── Kanban ─── */
.kanban-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 28px; }
.kanban { flex: 1; display: flex; gap: 12px; overflow-x: auto; padding-bottom: 20px; align-items: flex-start; }
.lane { width: 270px; flex-shrink: 0; background: var(--surface-2); border-radius: 8px; max-height: 100%; display: flex; flex-direction: column; }
.lane.over { outline: 2px dashed var(--primary); }
.lane-head { padding: 10px 14px; border-radius: 8px 8px 0 0; font-weight: 600; display: flex; justify-content: space-between; }
.lane-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; }
.kcard { background: var(--surface); border-radius: 6px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); cursor: pointer; display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--border-soft); }
.kcard:hover { box-shadow: var(--shadow); }
.kcard-name { font-weight: 500; }
.kcard-field { display: flex; flex-direction: column; gap: 2px; }
.kcard-field .tcell, .kcard-field > div { justify-content: flex-start; }
.kcard-foot { display: flex; align-items: center; justify-content: space-between; }
.lane-add { display: flex; align-items: center; gap: 6px; padding: 8px; color: var(--muted); border-radius: 4px; }
.lane-add:hover { background: var(--hover); }
/* ─── Calendar ─── */
.calendar { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 28px 16px; overflow: auto; }
.cal-head { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.cal-head .input { width: auto; }
.cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); border-left: 1px solid var(--border-soft); border-top: 1px solid var(--border-soft); flex: 1; grid-template-rows: 30px; grid-auto-rows: minmax(110px, 1fr); }
.cal-dow { padding: 6px 8px; font-size: 12px; color: var(--muted); border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); grid-row: 1; height: 30px; }
.cal-grid > .cal-dow { min-height: 0; }
.cal-day { border-right: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); padding: 4px; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.cal-day.out { background: var(--surface-2); }
.cal-day.out .cal-num { color: var(--faint); }
.cal-num { font-size: 12px; padding: 2px 4px; align-self: flex-end; border-radius: 10px; }
.cal-day.today .cal-num { background: var(--primary); color: #fff; }
.cal-items { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cal-item { display: block; text-align: left; font-size: 12px; padding: 3px 6px; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; }
.agenda-day { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border-soft); }
.agenda-date { width: 90px; flex-shrink: 0; font-weight: 600; font-size: 13px; }
.agenda-date.today { color: var(--primary); }
.agenda-items { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.agenda-items .cal-item { font-size: 14px; padding: 8px 10px; border-radius: 6px; }
/* ─── Timeline ─── */
.timeline { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 28px 16px; }
.tl-body { flex: 1; min-height: 0; display: flex; border: 1px solid var(--border-soft); border-radius: 8px; overflow: auto; }
.tl-names { width: 220px; flex-shrink: 0; border-right: 1px solid var(--border-soft); position: sticky; left: 0; background: var(--surface); z-index: 2; }
.tl-corner { height: 36px; border-bottom: 1px solid var(--border-soft); position: sticky; top: 0; background: var(--surface); }
.tl-group, .tl-group-row { height: 34px; display: flex; align-items: center; padding: 0 12px; font-weight: 600; background: var(--surface-2); border-bottom: 1px solid var(--border-soft); }
.tl-name { height: 38px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid var(--border-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.tl-name:hover { background: var(--row-hover); }
.tl-scroll { flex: 1; }
.tl-canvas { position: relative; }
.tl-ticks { height: 36px; position: sticky; top: 0; background: var(--surface); z-index: 1; border-bottom: 1px solid var(--border-soft); }
.tl-ticks span { position: absolute; top: 10px; font-size: 12px; color: var(--muted); white-space: nowrap; border-left: 1px solid var(--border-soft); padding-left: 4px; height: 26px; }
.tl-today { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--primary); z-index: 1; opacity: .7; }
.tl-row { height: 38px; position: relative; border-bottom: 1px solid var(--border-soft); }
.tl-item { position: absolute; top: 7px; height: 24px; border-radius: 12px; color: #fff; display: flex; align-items: center; gap: 4px; padding: 0 4px 0 10px; font-size: 12px; cursor: grab; overflow: hidden; touch-action: none; user-select: none; }
.tl-item:active { cursor: grabbing; }
.tl-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-resize { position: absolute; right: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; }
.tl-set { position: absolute; top: 9px; }
/* ─── Chart ─── */
.chart-view { padding: 0 28px 40px; overflow: auto; display: flex; flex-direction: column; gap: 16px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
.kpi, .chart-card, .card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 8px; padding: 18px; }
.kpi { display: flex; flex-direction: column; gap: 4px; }
.kpi-n { font-size: 32px; font-weight: 600; }
.bars { display: flex; flex-direction: column; gap: 10px; }
.bar-row { display: grid; grid-template-columns: minmax(80px, 180px) 1fr 80px; align-items: center; gap: 12px; }
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { height: 26px; background: var(--surface-2); border-radius: 4px; overflow: hidden; }
.bar { display: block; height: 100%; border-radius: 4px; transition: width .3s; }
.pie-wrap { display: flex; align-items: center; gap: 32px; flex-wrap: wrap; }
.legend { display: flex; flex-direction: column; gap: 6px; }
.legend i { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 8px; vertical-align: middle; }
/* ─── Pages ─── */
.page { flex: 1; overflow-y: auto; padding: 0 32px 60px; }
.page-head { display: flex; align-items: center; gap: 12px; padding: 24px 0 18px; position: sticky; top: 0; background: var(--surface); z-index: 5; }
.page-head h1 { font-size: 26px; }
.page-head p { margin: 4px 0 0; }
.page-head .input { width: auto; }
.home-grid { display: flex; flex-direction: column; gap: 28px; max-width: 1100px; }
.home-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; padding: 12px; }
.stat { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 14px; border-radius: 8px; text-align: left; }
.stat:hover { background: var(--hover); }
.stat-n { font-size: 30px; font-weight: 600; }
.section-title { display: flex; align-items: center; gap: 6px; font-size: 16px; margin: 18px 0 10px; }
.board-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.board-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; gap: 6px; text-align: left; transition: box-shadow .15s; }
.board-card:hover { box-shadow: var(--shadow); }
.board-card.new { align-items: center; justify-content: center; color: var(--muted); min-height: 150px; border-style: dashed; }
.bc-preview { height: 84px; background: var(--surface-2); border-radius: 6px; padding: 10px; display: flex; flex-direction: column; gap: 6px; }
.bc-preview i { display: block; height: 10px; border-radius: 2px; background: linear-gradient(90deg, #579bfc 0 6px, var(--surface-3) 6px 60%, #00c875 60% 75%, #fdab3d 75%); }
.bc-preview i:nth-child(2) { background: linear-gradient(90deg, #579bfc 0 6px, var(--surface-3) 6px 60%, #fdab3d 60% 75%, #e2445c 75%); }
.bc-name { display: flex; align-items: center; gap: 6px; font-weight: 600; }
.mw-group { margin-bottom: 24px; }
.mw-group h3 { font-size: 17px; margin-bottom: 8px; display: flex; gap: 8px; align-items: baseline; }
.mw-row { display: grid; grid-template-columns: 28px minmax(0, 2fr) minmax(0, 1.4fr) 130px 100px; gap: 10px; align-items: center; min-height: 42px; border-bottom: 1px solid var(--border-soft); }
.mw-row.done .mw-name { text-decoration: line-through; color: var(--muted); }
.mw-check { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border); color: transparent; display: grid; place-items: center; }
.mw-check:hover { border-color: var(--green); color: var(--green); }
.mw-check.on { background: var(--green); border-color: var(--green); color: #fff; }
.mw-name { text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.mw-name:hover { color: var(--primary); }
.mw-board { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; }
.mw-status { height: 30px; display: grid; place-items: center; font-size: 13px; padding: 0 6px; overflow: hidden; white-space: nowrap; }
.mw-status.empty { background: var(--status-empty); opacity: .5; }
.mw-date { font-size: 13px; text-align: center; }
.mw-date.overdue { color: var(--red); font-weight: 600; }
.notif-list { display: flex; flex-direction: column; margin-top: 16px; max-width: 900px; }
.notif { display: flex; gap: 12px; align-items: flex-start; padding: 14px 12px; border-bottom: 1px solid var(--border-soft); cursor: pointer; border-radius: 6px; }
.notif:hover { background: var(--row-hover); }
.notif.unread { background: var(--row-selected); }
.notif-av { position: relative; }
.notif-kind { position: absolute; right: -4px; bottom: -4px; width: 20px; height: 20px; border-radius: 50%; background: var(--primary); color: #fff; display: grid; place-items: center; border: 2px solid var(--surface); }
.notif-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; line-height: 1.4; }
.read-dot { width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--faint); }
.read-dot.on { background: var(--primary); border-color: var(--primary); }
.big-search { display: flex; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 0 16px; height: 52px; max-width: 760px; color: var(--muted); }
.big-search:focus-within { border-color: var(--primary); }
.big-search input { border: 0; outline: none; background: none; flex: 1; font-size: 17px; color: var(--text); }
.search-page .section-title { margin-top: 28px; }
.result { display: flex; align-items: center; gap: 10px; width: 100%; max-width: 900px; padding: 10px 12px; border-radius: 6px; text-align: left; }
.result:hover { background: var(--hover); }
.people-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; }
.person-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 10px; padding: 20px 14px; display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; }
.person-card.inactive { opacity: .45; }
.person-card a { overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.admin .panel-tabs { padding: 0; margin-bottom: 20px; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th { text-align: left; font-weight: 500; color: var(--muted); font-size: 13px; padding: 8px; border-bottom: 1px solid var(--border-soft); }
.admin-table td { padding: 8px; border-bottom: 1px solid var(--border-soft); }
.admin-table .input { width: 120px; }
.admin-table tr.inactive td { opacity: .5; }
.import-card { max-width: 820px; display: flex; flex-direction: column; gap: 12px; }
.steps { margin: 0; padding-left: 20px; line-height: 1.7; color: var(--muted); }
.steps b { color: var(--text); }
.import-list { max-height: 340px; overflow-y: auto; border: 1px solid var(--border-soft); border-radius: 6px; padding: 6px 12px; }
.import-log pre { background: var(--surface-2); padding: 12px; border-radius: 6px; max-height: 320px; overflow: auto; font-size: 12px; white-space: pre-wrap; }
.user-menu { min-width: 260px; }
.um-head { display: flex; align-items: center; gap: 10px; padding: 16px 16px 8px; }
.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.tpl { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; text-align: left; }
.tpl .icon { color: var(--primary); }
.tpl.on { border-color: var(--primary); background: var(--primary-soft); }
.automations .auto-row { display: flex; align-items: center; gap: 10px; padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; margin-bottom: 8px; }
.auto-row .icon { color: var(--orange); }
.auto-row.off { opacity: .55; }
.auto-text { flex: 1; line-height: 1.5; }
.auto-builder { border: 1px solid var(--primary); border-radius: 8px; padding: 16px; margin-top: 8px; }
.auto-sentence { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 16px; }
.auto-sentence .input { width: auto; }
.auto-sentence .grow { min-width: 200px; }
/* ─── Mobile ─── */
.mobile-tabs { display: none; }
@media (max-width: 768px) {
body { font-size: 15px; }
.app { flex-direction: column; }
.main { margin: 0; border-radius: 0; padding-bottom: calc(60px + env(safe-area-inset-bottom)); padding-top: env(safe-area-inset-top); }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 200; transform: translateX(-105%); transition: transform .22s ease; box-shadow: var(--shadow-lg); padding-top: env(safe-area-inset-top); background: var(--rail); }
.sidebar.open { transform: none; }
.sidebar.collapsed .nav-panel { display: flex; }
.nav-collapsed .nav-panel { display: flex; }
.expand-btn, .collapse-btn { display: none; }
.nav-panel { width: min(290px, 78vw); margin: 0; border-radius: 0; }
.drawer-backdrop { position: fixed; inset: 0; background: #0007; z-index: 150; }
.nav-board, .nav-folder-row { height: 42px; }
.nav-more { opacity: 1; }
.mobile-tabs { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 100; background: var(--surface); border-top: 1px solid var(--border-soft); padding-bottom: env(safe-area-inset-bottom); }
.mtab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 0 6px; font-size: 11px; color: var(--muted); position: relative; }
.mtab.active { color: var(--primary); }
.mtab .rail-badge { top: 2px; right: calc(50% - 22px); border-color: var(--surface); }
.board-header { padding: 10px 12px 0; }
.board-title { font-size: 20px; }
.toolbar { padding: 10px 12px; gap: 2px; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.tb-search input { width: 120px; }
.page { padding: 0 16px 40px; }
.page-head { padding: 14px 0 12px; }
.page-head h1 { font-size: 22px; }
.mw-row { grid-template-columns: 28px 1fr auto; grid-template-areas: "c n d" "c b s"; padding: 8px 0; row-gap: 4px; }
.mw-check { grid-area: c; } .mw-name { grid-area: n; } .mw-board { grid-area: b; } .mw-status { grid-area: s; height: 24px; border-radius: 12px; font-size: 12px; min-width: 90px; } .mw-date { grid-area: d; }
.item-panel { width: 100vw; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
.panel-body { padding: 14px 12px 40px; }
.panel-title { padding: 6px 14px 8px; font-size: 20px; }
.panel-tabs { padding: 0 10px; }
.info-row { grid-template-columns: 120px 1fr; }
.info-label { padding: 0 10px; font-size: 13px; }
.replies { padding-left: 16px; }
.kanban-wrap, .calendar, .timeline, .chart-view { padding-left: 12px; padding-right: 12px; }
.file-gallery { padding: 0 12px 40px; grid-template-columns: repeat(2, 1fr); }
.lane { width: 82vw; }
.tl-names { width: 130px; }
.bulk-label { display: none; }
.filter-rule { min-width: 0; grid-template-columns: 1fr 1fr; }
.filter-rule > .muted { display: none; }
.quick-filters { flex-direction: column; }
.popover.sheet .picker, .popover.sheet .datepicker, .popover.sheet .status-picker, .popover.sheet .longtext-editor, .popover.sheet .form-pop, .popover.sheet .files-pop, .popover.sheet .pop-pad, .popover.sheet .label-editor { width: 100%; max-width: none; min-width: 0; }
.popover.sheet .menu-item { padding: 12px 10px; }
.popover.sheet .dp-day { height: 42px; }
.col-type-menu .menu { min-width: 0; }
.modal-backdrop { align-items: flex-end; padding: 0; }
.modal { border-radius: 16px 16px 0 0; max-height: 92vh; padding-bottom: env(safe-area-inset-bottom); }
.dialog { padding: 20px 16px; }
.act-row { grid-template-columns: 40px 24px 1fr; }
.act-row .act-what { grid-column: 2 / -1; }
.bar-row { grid-template-columns: 90px 1fr 60px; }
.board-cards { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.bc-preview { height: 60px; }
.admin-table th:nth-child(2), .admin-table td:nth-child(2), .admin-table th:nth-child(4), .admin-table td:nth-child(4) { display: none; }
}
/* phone board list */
.mobile-list { flex: 1; overflow-y: auto; padding: 0 12px 30px; display: flex; flex-direction: column; gap: 14px; }
.m-group { --g: #579bfc; }
.m-group-head { display: flex; align-items: center; gap: 6px; width: 100%; padding: 8px 2px; font-weight: 600; font-size: 16px; }
.m-card { display: flex; flex-direction: column; gap: 8px; width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--border-soft); border-left: 4px solid var(--g); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.m-card:active { background: var(--row-hover); }
.m-card-top { display: flex; align-items: flex-start; gap: 8px; }
.m-name { flex: 1; font-weight: 500; line-height: 1.35; }
.m-card-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.m-status { padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.m-date { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); }
.m-card-meta .muted { display: inline-flex; align-items: center; gap: 3px; }
.m-add { display: flex; align-items: center; gap: 6px; padding: 10px; color: var(--muted); width: 100%; }
.m-add-input { margin-bottom: 8px; }
.m-add-group { align-self: flex-start; }
@media (hover: none) {
.row-menu, .open-item, .th-menu, .group-menu-btn, .nav-more { opacity: 1; }
}
@media print {
.sidebar, .toolbar, .mobile-tabs, .view-tabs { display: none !important; }
body { overflow: visible; }
}
.empty-updates .icon { margin: 0 auto; }
+17
View File
@@ -0,0 +1,17 @@
// Network-first for the app shell so a deploy is picked up on next load; cached copy
// only when offline. API calls and files are never cached here.
const CACHE = 'wb-shell-v1';
self.addEventListener('install', (e) => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/files/')) return;
e.respondWith(
fetch(e.request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
return res;
}).catch(() => caches.match(e.request).then((r) => r || caches.match('/')))
);
});
File diff suppressed because one or more lines are too long
+86
View File
@@ -0,0 +1,86 @@
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
const path = require('path');
const express = require('express');
const cookieParser = require('cookie-parser');
const auth = require('./src/auth');
const realtime = require('./src/realtime');
const api = require('./src/api');
const automations = require('./src/automations');
const importer = require('./src/importMonday');
const { get, run, id, now } = require('./src/db');
const PORT = Number(process.env.PORT || 3100);
const app = express();
app.set('trust proxy', true); // behind the NAS reverse proxy, so req.secure reflects https
app.disable('x-powered-by');
app.use(express.json({ limit: '5mb' }));
app.use(cookieParser());
app.use((req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Google's sign-in popup needs to postMessage back to this page.
res.set('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
next();
});
app.get('/healthz', (req, res) => res.json({ ok: true }));
auth.mount(app);
realtime.mount(app, auth.requireUser);
api.mountFiles(app);
// Admin: monday.com import
app.post('/api/admin/import/monday/boards', auth.requireUser, auth.requireAdmin, async (req, res) => {
try { res.json(await importer.listBoards(String(req.body.token || '').trim())); }
catch (e) { res.status(400).json({ error: e.message }); }
});
app.post('/api/admin/import/monday', auth.requireUser, auth.requireAdmin, (req, res) => {
const token = String(req.body.token || '').trim();
if (!token) return res.status(400).json({ error: 'Paste a monday.com API token' });
if (importer.status.running) return res.status(409).json({ error: 'An import is already running' });
importer.importAll(token, { boardIds: req.body.boardIds }); // runs in the background; poll status
res.json({ started: true });
});
app.get('/api/admin/import/monday', auth.requireUser, auth.requireAdmin, (req, res) => res.json(importer.status));
app.use('/api', api.router);
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, p) => {
if (/\.(html|webmanifest)$|sw\.js$/.test(p)) res.set('Cache-Control', 'no-cache');
},
}));
// SPA routes
app.get(/^\/(?!api|files).*/, (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
// First run: make sure there's a workspace to land in.
if (!get('SELECT 1 FROM workspaces LIMIT 1')) {
run('INSERT INTO workspaces(id, name, description, color, position, created_at) VALUES(?,?,?,?,?,?)',
id(), process.env.ORG_NAME || 'Waters Church', '', '#579bfc', 1, now());
}
automations.scheduleDaily();
// Nightly snapshot of the database into data/backups (keeps 14). VACUUM INTO writes a
// consistent copy while the app keeps running; uploads are plain files beside it.
const fs = require('fs');
const { db, DATA_DIR, getSetting, setSetting } = require('./src/db');
setInterval(() => {
const d = new Date();
const key = d.toISOString().slice(0, 10);
if (d.getHours() < 2 || getSetting('lastBackup') === key) return;
try {
const dir = path.join(DATA_DIR, 'backups');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `boards-${key}.db`);
if (!fs.existsSync(file)) db.prepare('VACUUM INTO ?').run(file);
setSetting('lastBackup', key);
const old = fs.readdirSync(dir).filter((f) => /^boards-.*\.db$/.test(f)).sort().slice(0, -14);
for (const f of old) fs.unlinkSync(path.join(dir, f));
} catch (e) { console.error('backup failed', e.message); }
}, 10 * 60_000).unref();
app.listen(PORT, () => console.log(`Waters Boards listening on :${PORT}`));
+857
View File
@@ -0,0 +1,857 @@
// REST API. All routes require a signed-in user; workspace structure is shared by the
// whole team (this is an internal tool), admin-only routes are marked.
const fs = require('fs');
const path = require('path');
const express = require('express');
const multer = require('multer');
const { db, DATA_DIR, id, now, all, get, run, tx } = require('./db');
const { requireUser, requireAdmin, publicUser } = require('./auth');
const { GROUP_COLORS, LABEL_COLORS, COLUMN_TYPES, TEMPLATES, defaultSettings } = require('./templates');
const M = require('./model');
const automations = require('./automations');
const realtime = require('./realtime');
const UPLOADS = path.join(DATA_DIR, 'uploads');
const upload = multer({
storage: multer.diskStorage({
destination: UPLOADS,
filename: (req, file, cb) => cb(null, id() + path.extname(file.originalname).slice(0, 12)),
}),
limits: { fileSize: 100 * 1024 * 1024 },
});
const r = express.Router();
r.use(requireUser);
const origin = (req) => req.get('X-Client-Id') || null;
const wrap = (fn) => (req, res) => {
try {
const out = fn(req, res);
if (out && typeof out.then === 'function') out.catch((e) => fail(res, e));
} catch (e) { fail(res, e); }
};
function fail(res, e) {
if (!e.status) console.error(e);
res.status(e.status || 500).json({ error: e.status ? e.message : 'Server error' });
}
const httpErr = (status, message) => Object.assign(new Error(message), { status });
const must = (row, what = 'Not found') => { if (!row) throw httpErr(404, what); return row; };
function nextPos(table, where, ...p) {
const r = get(`SELECT MAX(position) m FROM ${table} WHERE ${where}`, ...p);
return (r?.m ?? 0) + 1;
}
function firstPos(table, where, ...p) {
const r = get(`SELECT MIN(position) m FROM ${table} WHERE ${where}`, ...p);
return (r?.m ?? 1) - 1;
}
// ─── Bootstrap / tree ─────────────────────────────────────────────
r.get('/bootstrap', wrap((req, res) => {
res.json({
me: publicUser(req.user),
users: all('SELECT * FROM users ORDER BY name').map(publicUser),
...tree(req.user.id),
unread: get('SELECT COUNT(*) n FROM notifications WHERE user_id = ? AND read = 0', req.user.id).n,
online: realtime.onlineUserIds(),
});
}));
r.get('/tree', wrap((req, res) => res.json(tree(req.user.id))));
function tree(userId) {
return {
workspaces: all('SELECT * FROM workspaces ORDER BY position'),
folders: all('SELECT * FROM folders ORDER BY position'),
boards: all('SELECT id, workspace_id, folder_id, name, position FROM boards WHERE archived = 0 ORDER BY position'),
favorites: all('SELECT board_id FROM favorites WHERE user_id = ?', userId).map((f) => f.board_id),
};
}
// ─── Workspaces & folders ────────────────────────────────────────
r.post('/workspaces', wrap((req, res) => {
const wid = id();
run('INSERT INTO workspaces(id, name, description, color, position, created_at) VALUES(?,?,?,?,?,?)',
wid, req.body.name?.trim() || 'New workspace', req.body.description || '', req.body.color || GROUP_COLORS[0], nextPos('workspaces', '1'), now());
M.emitTree(origin(req));
res.json(get('SELECT * FROM workspaces WHERE id = ?', wid));
}));
r.patch('/workspaces/:id', wrap((req, res) => {
must(get('SELECT 1 FROM workspaces WHERE id = ?', req.params.id));
for (const k of ['name', 'description', 'color', 'position']) {
if (k in req.body) run(`UPDATE workspaces SET ${k} = ? WHERE id = ?`, req.body[k], req.params.id);
}
M.emitTree(origin(req));
res.json({ ok: true });
}));
r.delete('/workspaces/:id', requireAdmin, wrap((req, res) => {
if (get('SELECT COUNT(*) n FROM workspaces').n <= 1) throw httpErr(400, 'You need at least one workspace');
run('DELETE FROM workspaces WHERE id = ?', req.params.id);
M.emitTree(origin(req));
res.json({ ok: true });
}));
r.post('/folders', wrap((req, res) => {
const fid = id();
must(get('SELECT 1 FROM workspaces WHERE id = ?', req.body.workspace_id), 'Workspace not found');
run('INSERT INTO folders(id, workspace_id, parent_id, name, color, position) VALUES(?,?,?,?,?,?)',
fid, req.body.workspace_id, req.body.parent_id || null, req.body.name?.trim() || 'New folder', req.body.color || null,
nextPos('folders', 'workspace_id = ?', req.body.workspace_id));
M.emitTree(origin(req));
res.json(get('SELECT * FROM folders WHERE id = ?', fid));
}));
r.patch('/folders/:id', wrap((req, res) => {
must(get('SELECT 1 FROM folders WHERE id = ?', req.params.id));
for (const k of ['name', 'color', 'position', 'parent_id', 'workspace_id']) {
if (k in req.body) run(`UPDATE folders SET ${k} = ? WHERE id = ?`, req.body[k], req.params.id);
}
if ('workspace_id' in req.body) run('UPDATE boards SET workspace_id = ? WHERE folder_id = ?', req.body.workspace_id, req.params.id);
M.emitTree(origin(req));
res.json({ ok: true });
}));
r.delete('/folders/:id', wrap((req, res) => {
// Boards inside are kept and moved to the folder's parent (or the workspace root).
const f = must(get('SELECT * FROM folders WHERE id = ?', req.params.id));
tx(() => {
run('UPDATE boards SET folder_id = ? WHERE folder_id = ?', f.parent_id, f.id);
run('UPDATE folders SET parent_id = ? WHERE parent_id = ?', f.parent_id, f.id);
run('DELETE FROM folders WHERE id = ?', f.id);
});
M.emitTree(origin(req));
res.json({ ok: true });
}));
// ─── Boards ──────────────────────────────────────────────────────
function createColumn(boardId, title, type, settings, position) {
let t = type;
let s = settings;
if (type === 'priority') { t = 'status'; s = s || defaultSettings('priority'); }
if (!COLUMN_TYPES.includes(t)) throw httpErr(400, 'Unknown column type');
const cid = id();
run('INSERT INTO columns(id, board_id, title, type, settings, position) VALUES(?,?,?,?,?,?)',
cid, boardId, title, t, JSON.stringify(s || defaultSettings(t)), position ?? nextPos('columns', 'board_id = ?', boardId));
return cid;
}
function createGroup(boardId, title, color, position) {
const gid = id();
const n = get('SELECT COUNT(*) n FROM groups WHERE board_id = ?', boardId).n;
run('INSERT INTO groups(id, board_id, title, color, position) VALUES(?,?,?,?,?)',
gid, boardId, title, color || GROUP_COLORS[n % GROUP_COLORS.length], position ?? nextPos('groups', 'board_id = ?', boardId));
return gid;
}
function defaultViews(boardId) {
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)', id(), boardId, 'table', 'Main table', '{}', 0);
}
r.post('/boards', wrap((req, res) => {
const { workspace_id, folder_id, name, template = 'blank' } = req.body;
must(get('SELECT 1 FROM workspaces WHERE id = ?', workspace_id), 'Workspace not found');
const t = TEMPLATES[template] || TEMPLATES.blank;
const bid = id();
tx(() => {
run('INSERT INTO boards(id, workspace_id, folder_id, name, description, item_label, position, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
bid, workspace_id, folder_id || null, name?.trim() || 'New board', '', t.itemLabel || 'Item',
nextPos('boards', 'workspace_id = ?', workspace_id), req.user.id, now());
t.columns.forEach(([title, type], i) => createColumn(bid, title, type, null, i + 1));
const gids = t.groups.map((g, i) => createGroup(bid, g, GROUP_COLORS[i % GROUP_COLORS.length], i + 1));
const seed = t.items || { 0: ['Item 1', 'Item 2', 'Item 3'] };
for (const [gi, names] of Object.entries(seed)) {
names.forEach((n, i) => run('INSERT INTO items(id, board_id, group_id, name, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)',
id(), bid, gids[gi], n, i + 1, req.user.id, now(), now()));
}
defaultViews(bid);
run('INSERT INTO favorites(user_id, board_id) VALUES(?,?) ON CONFLICT DO NOTHING', req.user.id, bid);
});
M.logActivity(bid, null, req.user.id, 'board_created', { name });
M.emitTree(origin(req));
res.json({ id: bid });
}));
r.get('/boards/:id', wrap((req, res) => {
const b = must(M.loadBoard(req.params.id), 'Board not found');
b.favorite = !!get('SELECT 1 FROM favorites WHERE user_id = ? AND board_id = ?', req.user.id, req.params.id);
res.json(b);
}));
r.patch('/boards/:id', wrap((req, res) => {
must(get('SELECT 1 FROM boards WHERE id = ?', req.params.id));
for (const k of ['name', 'description', 'folder_id', 'workspace_id', 'position', 'item_label']) {
if (k in req.body) run(`UPDATE boards SET ${k} = ? WHERE id = ?`, req.body[k], req.params.id);
}
M.emitTree(origin(req));
M.emitBoard(req.params.id, origin(req));
res.json({ ok: true });
}));
r.delete('/boards/:id', wrap((req, res) => {
run('DELETE FROM boards WHERE id = ?', req.params.id);
M.emitTree(origin(req));
M.emitBoard(req.params.id, origin(req));
res.json({ ok: true });
}));
r.post('/boards/:id/favorite', wrap((req, res) => {
if (req.body.on) run('INSERT INTO favorites(user_id, board_id) VALUES(?,?) ON CONFLICT DO NOTHING', req.user.id, req.params.id);
else run('DELETE FROM favorites WHERE user_id = ? AND board_id = ?', req.user.id, req.params.id);
res.json({ ok: true });
}));
r.post('/boards/:id/duplicate', wrap((req, res) => {
const src = must(M.loadBoard(req.params.id));
const withItems = req.body.items !== false;
const bid = id();
tx(() => {
const b = src.board;
run('INSERT INTO boards(id, workspace_id, folder_id, name, description, item_label, position, created_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
bid, b.workspace_id, b.folder_id, req.body.name || `${b.name} (copy)`, b.description, b.item_label, b.position + 0.5, req.user.id, now());
const colMap = {};
for (const c of src.columns) {
colMap[c.id] = id();
run('INSERT INTO columns(id, board_id, title, type, settings, width, position) VALUES(?,?,?,?,?,?,?)',
colMap[c.id], bid, c.title, c.type, JSON.stringify(c.settings), c.width, c.position);
}
const grpMap = {};
for (const g of src.groups) {
grpMap[g.id] = id();
run('INSERT INTO groups(id, board_id, title, color, position) VALUES(?,?,?,?,?)', grpMap[g.id], bid, g.title, g.color, g.position);
}
if (withItems) {
const itemMap = {};
const remap = (vals) => {
const out = {};
for (const [k, v] of Object.entries(vals)) if (colMap[k]) out[colMap[k]] = v;
// Files belong to the original item; don't share them between copies.
for (const c of src.columns) if (c.type === 'files') delete out[colMap[c.id]];
return out;
};
const ordered = [...src.items].sort((a, b) => (a.parent_id ? 1 : 0) - (b.parent_id ? 1 : 0));
for (const it of ordered) {
itemMap[it.id] = id();
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
itemMap[it.id], bid, grpMap[it.group_id] || null, it.parent_id ? itemMap[it.parent_id] : null, it.name,
JSON.stringify(remap(it.vals)), it.position, req.user.id, now(), now());
}
}
for (const v of src.views) {
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)', id(), bid, v.type, v.name, '{}', v.position);
}
});
M.emitTree(origin(req));
res.json({ id: bid });
}));
r.get('/boards/:id/activity', wrap((req, res) => {
res.json(all(`SELECT a.*, i.name item_name FROM activity a LEFT JOIN items i ON i.id = a.item_id
WHERE a.board_id = ? ORDER BY a.id DESC LIMIT 300`, req.params.id).map((a) => ({ ...a, data: M.parse(a.data, null) })));
}));
r.get('/boards/:id/export.csv', wrap((req, res) => {
const b = must(M.loadBoard(req.params.id));
const esc = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
const header = ['Group', b.board.item_label || 'Item', 'Parent item', ...b.columns.map((c) => c.title)];
const lines = [header.map(esc).join(',')];
const groupName = Object.fromEntries(b.groups.map((g) => [g.id, g.title]));
const byId = Object.fromEntries(b.items.map((i) => [i.id, i]));
for (const it of b.items) {
const cells = b.columns.map((c) => {
if (c.type === 'files') return b.files.filter((f) => f.item_id === it.id).map((f) => f.name).join(', ');
if (c.type === 'created') return new Date(it.created_at).toISOString().slice(0, 10);
if (c.type === 'updated') return new Date(it.updated_at).toISOString().slice(0, 10);
if (c.type === 'item_id') return it.id;
return M.formatValue(c, it.vals[c.id]);
});
const parent = it.parent_id ? byId[it.parent_id] : null;
lines.push([groupName[it.group_id || parent?.group_id] || '', it.name, parent?.name || '', ...cells].map(esc).join(','));
}
res.set('Content-Type', 'text/csv; charset=utf-8');
res.set('Content-Disposition', `attachment; filename="${b.board.name.replace(/[^\w\- ]/g, '')}.csv"`);
res.send('' + lines.join('\r\n'));
}));
// CSV import into an existing board: first row is headers; "Name"/first column is the item,
// "Group" picks/creates a group, other headers match columns by title (created as text if new).
r.post('/boards/:id/import.csv', express.text({ type: '*/*', limit: '20mb' }), wrap((req, res) => {
const b = must(M.loadBoard(req.params.id));
const rows = parseCsv(String(req.body || ''));
if (rows.length < 2) throw httpErr(400, 'The CSV has no rows');
const header = rows.shift().map((h) => h.trim());
const gIdx = header.findIndex((h) => /^group$/i.test(h));
const nIdx = Math.max(0, header.findIndex((h) => /^(name|item|title)$/i.test(h)));
let created = 0;
tx(() => {
const cols = header.map((h, i) => {
if (i === gIdx || i === nIdx || !h) return null;
const existing = b.columns.find((c) => c.title.toLowerCase() === h.toLowerCase());
if (existing) return existing;
const cid = createColumn(b.board.id, h, 'text');
const c = { id: cid, type: 'text', settings: {}, title: h };
b.columns.push(c);
return c;
});
const groups = new Map(b.groups.map((g) => [g.title.toLowerCase(), g.id]));
const defaultGroup = b.groups[0]?.id || createGroup(b.board.id, 'Imported');
for (const row of rows) {
if (!row.some((c) => c.trim())) continue;
let gid = defaultGroup;
if (gIdx >= 0 && row[gIdx]?.trim()) {
const key = row[gIdx].trim().toLowerCase();
if (!groups.has(key)) groups.set(key, createGroup(b.board.id, row[gIdx].trim()));
gid = groups.get(key);
}
const vals = {};
cols.forEach((c, i) => { if (c && row[i]?.trim()) vals[c.id] = coerceImported(c, row[i].trim()); });
run('INSERT INTO items(id, board_id, group_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), b.board.id, gid, row[nIdx]?.trim() || 'Untitled', JSON.stringify(vals), nextPos('items', 'board_id = ?', b.board.id), req.user.id, now(), now());
created++;
}
});
M.emitBoard(b.board.id, origin(req));
res.json({ created });
}));
function coerceImported(col, text) {
const s = col.settings || {};
switch (col.type) {
case 'number': { const n = Number(text.replace(/[^0-9.\-]/g, '')); return Number.isFinite(n) ? n : null; }
case 'checkbox': return /^(true|yes|1|✓|v)$/i.test(text);
case 'date': { const d = new Date(text); return isNaN(d) ? null : d.toISOString().slice(0, 10); }
case 'status': {
let l = (s.labels || []).find((x) => x.name.toLowerCase() === text.toLowerCase());
if (!l) {
l = { id: id(), name: text, color: LABEL_COLORS[(s.labels || []).length % LABEL_COLORS.length] };
s.labels = [...(s.labels || []), l];
run('UPDATE columns SET settings = ? WHERE id = ?', JSON.stringify(s), col.id);
}
return l.id;
}
case 'people': {
return text.split(/[,;]/).map((n) => n.trim().toLowerCase()).map((n) =>
get('SELECT id FROM users WHERE lower(name) = ? OR lower(email) = ?', n, n)?.id).filter(Boolean);
}
default: return text;
}
}
function parseCsv(text) {
const rows = []; let row = []; let cell = ''; let q = false;
text = text.replace(/^/, '');
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++; }
else if (ch === '"') q = false;
else cell += ch;
} else if (ch === '"') q = true;
else if (ch === ',') { row.push(cell); cell = ''; }
else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell); rows.push(row); row = []; cell = '';
} else cell += ch;
}
if (cell || row.length) { row.push(cell); rows.push(row); }
return rows;
}
// ─── Columns ─────────────────────────────────────────────────────
r.post('/boards/:id/columns', wrap((req, res) => {
must(get('SELECT 1 FROM boards WHERE id = ?', req.params.id));
const { title, type, settings, after } = req.body;
let position;
if (after) {
const a = get('SELECT position FROM columns WHERE id = ?', after);
const n = a && get('SELECT MIN(position) p FROM columns WHERE board_id = ? AND position > ?', req.params.id, a.position);
if (a) position = n?.p != null ? (a.position + n.p) / 2 : a.position + 1;
}
const cid = createColumn(req.params.id, title || defaultTitle(type), type, settings, position);
M.logActivity(req.params.id, null, req.user.id, 'column_created', { title: title || defaultTitle(type) });
M.emitBoard(req.params.id, origin(req));
res.json({ id: cid });
}));
function defaultTitle(type) {
return ({ text: 'Text', long_text: 'Long text', status: 'Status', priority: 'Priority', people: 'Person', date: 'Date', timeline: 'Timeline',
number: 'Numbers', checkbox: 'Check', dropdown: 'Dropdown', link: 'Link', email: 'Email', phone: 'Phone', rating: 'Rating',
files: 'Files', tags: 'Tags', created: 'Creation log', updated: 'Last updated', item_id: 'Item ID' })[type] || 'Column';
}
r.patch('/columns/:id', wrap((req, res) => {
const c = must(get('SELECT * FROM columns WHERE id = ?', req.params.id));
if ('title' in req.body) run('UPDATE columns SET title = ? WHERE id = ?', req.body.title, c.id);
if ('width' in req.body) run('UPDATE columns SET width = ? WHERE id = ?', req.body.width, c.id);
if ('position' in req.body) run('UPDATE columns SET position = ? WHERE id = ?', req.body.position, c.id);
if ('settings' in req.body) run('UPDATE columns SET settings = ? WHERE id = ?', JSON.stringify(req.body.settings), c.id);
if ('type' in req.body && req.body.type !== c.type) changeColumnType(c, req.body.type);
M.emitBoard(c.board_id, origin(req));
res.json({ ok: true });
}));
// Changing type keeps whatever converts cleanly as text; everything else is cleared.
function changeColumnType(c, type) {
if (!COLUMN_TYPES.includes(type)) throw httpErr(400, 'Unknown column type');
const col = M.columnOut(c);
tx(() => {
for (const it of all('SELECT id, vals FROM items WHERE board_id = ?', c.board_id)) {
const vals = M.parse(it.vals, {});
if (!(c.id in vals)) continue;
const text = M.formatValue(col, vals[c.id]);
if (['text', 'long_text'].includes(type)) vals[c.id] = text || null;
else if (type === 'number' && Number.isFinite(Number(text))) vals[c.id] = Number(text);
else delete vals[c.id];
run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(vals), it.id);
}
run('UPDATE columns SET type = ?, settings = ? WHERE id = ?', type, JSON.stringify(defaultSettings(type)), c.id);
});
}
r.delete('/columns/:id', wrap((req, res) => {
const c = must(get('SELECT * FROM columns WHERE id = ?', req.params.id));
run('DELETE FROM columns WHERE id = ?', c.id);
M.logActivity(c.board_id, null, req.user.id, 'column_deleted', { title: c.title });
M.emitBoard(c.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Groups ──────────────────────────────────────────────────────
r.post('/boards/:id/groups', wrap((req, res) => {
must(get('SELECT 1 FROM boards WHERE id = ?', req.params.id));
const pos = req.body.top ? firstPos('groups', 'board_id = ?', req.params.id) : undefined;
const gid = createGroup(req.params.id, req.body.title || 'New Group', req.body.color, pos);
M.emitBoard(req.params.id, origin(req));
res.json({ id: gid });
}));
r.patch('/groups/:id', wrap((req, res) => {
const g = must(get('SELECT * FROM groups WHERE id = ?', req.params.id));
for (const k of ['title', 'color', 'position']) if (k in req.body) run(`UPDATE groups SET ${k} = ? WHERE id = ?`, req.body[k], g.id);
if ('collapsed' in req.body) run('UPDATE groups SET collapsed = ? WHERE id = ?', req.body.collapsed ? 1 : 0, g.id);
M.emitBoard(g.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/groups/:id', wrap((req, res) => {
const g = must(get('SELECT * FROM groups WHERE id = ?', req.params.id));
if (get('SELECT COUNT(*) n FROM groups WHERE board_id = ?', g.board_id).n <= 1) throw httpErr(400, 'A board needs at least one group');
run('DELETE FROM groups WHERE id = ?', g.id);
M.logActivity(g.board_id, null, req.user.id, 'group_deleted', { title: g.title });
M.emitBoard(g.board_id, origin(req));
res.json({ ok: true });
}));
r.post('/groups/:id/duplicate', wrap((req, res) => {
const g = must(get('SELECT * FROM groups WHERE id = ?', req.params.id));
const gid = id();
tx(() => {
run('INSERT INTO groups(id, board_id, title, color, position) VALUES(?,?,?,?,?)', gid, g.board_id, `Duplicate of ${g.title}`, g.color, g.position + 0.5);
for (const it of all('SELECT * FROM items WHERE group_id = ? AND archived = 0 AND parent_id IS NULL', g.id)) {
run('INSERT INTO items(id, board_id, group_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), g.board_id, gid, it.name, it.vals, it.position, req.user.id, now(), now());
}
});
M.emitBoard(g.board_id, origin(req));
res.json({ id: gid });
}));
// ─── Items ───────────────────────────────────────────────────────
r.post('/boards/:id/items', wrap((req, res) => {
const boardId = req.params.id;
must(get('SELECT 1 FROM boards WHERE id = ?', boardId), 'Board not found');
const { name, parent_id, vals = {}, top } = req.body;
let group_id = req.body.group_id;
if (parent_id) group_id = null;
else if (!group_id || !get('SELECT 1 FROM groups WHERE id = ? AND board_id = ?', group_id, boardId)) {
group_id = get('SELECT id FROM groups WHERE board_id = ? ORDER BY position LIMIT 1', boardId)?.id || createGroup(boardId, 'Group Title');
}
const where = parent_id ? ['parent_id = ?', parent_id] : ['group_id = ? AND parent_id IS NULL', group_id];
const position = req.body.position ?? (top ? firstPos('items', where[0], where[1]) : nextPos('items', where[0], where[1]));
const iid = id();
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
iid, boardId, group_id, parent_id || null, String(name || 'New item').slice(0, 500), JSON.stringify(vals), position, req.user.id, now(), now());
M.logActivity(boardId, iid, req.user.id, 'item_created', { name, parent_id });
const cols = all('SELECT * FROM columns WHERE board_id = ?', boardId).map(M.columnOut);
for (const c of cols) {
if (c.type === 'people' && Array.isArray(vals[c.id]) && vals[c.id].length) {
M.notify(vals[c.id], { actorId: req.user.id, boardId, itemId: iid, kind: 'assigned', text: `${req.user.name} assigned you to "${name}"` });
}
}
if (!parent_id) automations.run({ type: 'item_created', boardId, itemId: iid });
M.emitBoard(boardId, origin(req));
res.json(M.itemOut(get('SELECT * FROM items WHERE id = ?', iid)));
}));
// The single path every item mutation goes through — the API, bulk actions and automations —
// so activity, notifications and automation triggers are consistent.
function applyPatch(itemId, patch, ctx) {
const row = must(get('SELECT * FROM items WHERE id = ?', itemId), 'Item not found');
const item = M.itemOut(row);
const cols = all('SELECT * FROM columns WHERE board_id = ?', item.board_id).map(M.columnOut);
const colById = Object.fromEntries(cols.map((c) => [c.id, c]));
const events = [];
const actor = ctx.userId;
const actorName = actor ? M.userName(actor) : 'Automation';
if ('name' in patch && patch.name !== item.name) {
run('UPDATE items SET name = ? WHERE id = ?', String(patch.name).slice(0, 500), itemId);
M.logActivity(item.board_id, itemId, actor, 'renamed', { from: item.name, to: patch.name });
}
if ('group_id' in patch && patch.group_id !== item.group_id && !item.parent_id) {
const g = must(get('SELECT * FROM groups WHERE id = ?', patch.group_id), 'Group not found');
const pos = patch.position ?? nextPos('items', 'group_id = ? AND parent_id IS NULL', g.id);
const toBoard = g.board_id;
run('UPDATE items SET group_id = ?, board_id = ?, position = ? WHERE id = ?', g.id, toBoard, pos, itemId);
if (toBoard !== item.board_id) {
run('UPDATE items SET board_id = ? WHERE parent_id = ?', toBoard, itemId);
// Values keyed by column id don't mean anything on another board; keep those whose
// column title + type match a column there.
const target = all('SELECT * FROM columns WHERE board_id = ?', toBoard).map(M.columnOut);
const remapped = {};
for (const [k, v] of Object.entries(item.vals)) {
const src = colById[k];
const dst = src && target.find((t) => t.title === src.title && t.type === src.type);
if (dst) remapped[dst.id] = v;
}
run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(remapped), itemId);
item.vals = remapped;
M.logActivity(toBoard, itemId, actor, 'moved_board', { from: item.board_id });
M.emitBoard(toBoard, ctx.origin);
} else {
M.logActivity(item.board_id, itemId, actor, 'moved_group', { to: g.title });
}
} else if ('position' in patch) {
run('UPDATE items SET position = ? WHERE id = ?', patch.position, itemId);
}
if (patch.vals && typeof patch.vals === 'object') {
const vals = { ...item.vals };
for (const [cid, v] of Object.entries(patch.vals)) {
const col = colById[cid];
if (!col) continue;
const old = vals[cid];
if (JSON.stringify(old ?? null) === JSON.stringify(v ?? null)) continue;
if (v === null || v === '' || (Array.isArray(v) && !v.length)) delete vals[cid]; else vals[cid] = v;
M.logActivity(item.board_id, itemId, actor, 'value', {
column: col.title, type: col.type, from: M.formatValue(col, old), to: M.formatValue(col, v),
});
events.push({ type: 'value_changed', boardId: item.board_id, itemId, columnId: cid, oldVal: old, newVal: v });
if (col.type === 'people') {
const added = (Array.isArray(v) ? v : []).filter((u) => !(Array.isArray(old) ? old : []).includes(u));
M.notify(added, { actorId: actor, boardId: item.board_id, itemId, kind: 'assigned', text: `${actorName} assigned you to "${patch.name || item.name}"` });
}
}
run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(vals), itemId);
}
if (patch.archived) {
run('UPDATE items SET archived = 1 WHERE id = ? OR parent_id = ?', itemId, itemId);
M.logActivity(item.board_id, itemId, actor, 'archived', { name: item.name });
}
run('UPDATE items SET updated_at = ? WHERE id = ?', now(), itemId);
M.emitBoard(item.board_id, ctx.origin);
for (const ev of events) automations.run(ev, ctx.depth || 0);
return M.itemOut(get('SELECT * FROM items WHERE id = ?', itemId));
}
automations.setPatcher(applyPatch);
r.patch('/items/:id', wrap((req, res) => {
res.json(applyPatch(req.params.id, req.body, { userId: req.user.id, origin: origin(req) }));
}));
r.delete('/items/:id', wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
deleteItem(it);
M.logActivity(it.board_id, null, req.user.id, 'item_deleted', { name: it.name });
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
function deleteItem(it) {
const files = all('SELECT stored FROM files WHERE item_id IN (SELECT id FROM items WHERE id = ? OR parent_id = ?)', it.id, it.id);
run('DELETE FROM items WHERE id = ?', it.id);
for (const f of files) fs.unlink(path.join(UPLOADS, f.stored), () => {});
}
r.post('/items/:id/duplicate', wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
const nid = id();
tx(() => {
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
nid, it.board_id, it.group_id, it.parent_id, `${it.name} (copy)`, stripFiles(it), it.position + 0.01, req.user.id, now(), now());
if (req.body.subitems !== false) {
for (const s of all('SELECT * FROM items WHERE parent_id = ? AND archived = 0', it.id)) {
run('INSERT INTO items(id, board_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), s.board_id, nid, s.name, stripFiles(s), s.position, req.user.id, now(), now());
}
}
});
M.emitBoard(it.board_id, origin(req));
res.json({ id: nid });
}));
function stripFiles(it) {
const vals = M.parse(it.vals, {});
for (const c of all("SELECT id FROM columns WHERE board_id = ? AND type = 'files'", it.board_id)) delete vals[c.id];
return JSON.stringify(vals);
}
r.post('/items/bulk', wrap((req, res) => {
const { ids = [], action, group_id, vals } = req.body;
const boards = new Set();
for (const iid of ids) {
const it = get('SELECT * FROM items WHERE id = ?', iid);
if (!it) continue;
boards.add(it.board_id);
const ctx = { userId: req.user.id, origin: origin(req) };
if (action === 'delete') { deleteItem(it); M.logActivity(it.board_id, null, req.user.id, 'item_deleted', { name: it.name }); }
else if (action === 'archive') applyPatch(iid, { archived: true }, ctx);
else if (action === 'move') applyPatch(iid, { group_id }, ctx);
else if (action === 'set') applyPatch(iid, { vals }, ctx);
else if (action === 'duplicate') {
run('INSERT INTO items(id, board_id, group_id, parent_id, name, vals, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
id(), it.board_id, it.group_id, it.parent_id, `${it.name} (copy)`, stripFiles(it), it.position + 0.01, req.user.id, now(), now());
}
}
for (const b of boards) M.emitBoard(b, origin(req));
res.json({ ok: true });
}));
r.get('/items/:id', wrap((req, res) => {
const row = must(get('SELECT * FROM items WHERE id = ?', req.params.id), 'Item not found');
const item = M.itemOut(row);
const updates = all('SELECT * FROM updates WHERE item_id = ? ORDER BY created_at DESC', item.id).map((u) => ({
...u,
likes: all('SELECT user_id FROM update_likes WHERE update_id = ?', u.id).map((l) => l.user_id),
files: all('SELECT id, name, mime, size FROM files WHERE update_id = ?', u.id),
}));
const files = all('SELECT id, column_id, name, mime, size, uploaded_by, created_at FROM files WHERE item_id = ? ORDER BY created_at DESC', item.id);
const activity = all('SELECT * FROM activity WHERE item_id = ? ORDER BY id DESC LIMIT 200', item.id).map((a) => ({ ...a, data: M.parse(a.data, null) }));
const board = get('SELECT id, name, workspace_id FROM boards WHERE id = ?', item.board_id);
res.json({ item, updates, files, activity, board });
}));
// ─── Updates (comments) ──────────────────────────────────────────
r.post('/items/:id/updates', upload.array('files', 10), wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
const body = String(req.body.body || '').trim();
if (!body && !req.files?.length) throw httpErr(400, 'Write something first');
const uid = id();
run('INSERT INTO updates(id, item_id, parent_id, user_id, body, created_at) VALUES(?,?,?,?,?,?)',
uid, it.id, req.body.parent_id || null, req.user.id, body, now());
for (const f of req.files || []) {
run('INSERT INTO files(id, item_id, update_id, name, stored, mime, size, uploaded_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
id(), it.id, uid, f.originalname, f.filename, f.mimetype, f.size, req.user.id, now());
}
run('UPDATE items SET updated_at = ? WHERE id = ?', now(), it.id);
// Who hears about it: anyone @mentioned, the people on the item, and earlier participants.
let mentions = [];
try { mentions = JSON.parse(req.body.mentions || '[]'); } catch { /* ignore */ }
const cols = all('SELECT * FROM columns WHERE board_id = ?', it.board_id).map(M.columnOut);
const preview = body.replace(/\s+/g, ' ').slice(0, 120);
M.notify(mentions, { actorId: req.user.id, boardId: it.board_id, itemId: it.id, kind: 'mention', text: `${req.user.name} mentioned you on "${it.name}": ${preview}` });
const others = [...M.peopleOnItem(M.itemOut(it), cols), ...all('SELECT DISTINCT user_id FROM updates WHERE item_id = ?', it.id).map((u) => u.user_id)]
.filter((u) => !mentions.includes(u));
M.notify(others, { actorId: req.user.id, boardId: it.board_id, itemId: it.id, kind: req.body.parent_id ? 'reply' : 'update', text: `${req.user.name} ${req.body.parent_id ? 'replied' : 'wrote an update'} on "${it.name}": ${preview}` });
M.logActivity(it.board_id, it.id, req.user.id, 'update_posted', {});
M.emitBoard(it.board_id, origin(req));
res.json({ id: uid });
}));
r.patch('/updates/:id', wrap((req, res) => {
const u = must(get('SELECT * FROM updates WHERE id = ?', req.params.id));
if (u.user_id !== req.user.id && req.user.role !== 'admin') throw httpErr(403, 'You can only edit your own updates');
run('UPDATE updates SET body = ?, edited_at = ? WHERE id = ?', String(req.body.body || ''), now(), u.id);
const it = get('SELECT board_id FROM items WHERE id = ?', u.item_id);
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/updates/:id', wrap((req, res) => {
const u = must(get('SELECT * FROM updates WHERE id = ?', req.params.id));
if (u.user_id !== req.user.id && req.user.role !== 'admin') throw httpErr(403, 'You can only delete your own updates');
run('DELETE FROM updates WHERE id = ?', u.id);
const it = get('SELECT board_id FROM items WHERE id = ?', u.item_id);
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
r.post('/updates/:id/like', wrap((req, res) => {
const u = must(get('SELECT * FROM updates WHERE id = ?', req.params.id));
if (get('SELECT 1 FROM update_likes WHERE update_id = ? AND user_id = ?', u.id, req.user.id)) {
run('DELETE FROM update_likes WHERE update_id = ? AND user_id = ?', u.id, req.user.id);
} else {
run('INSERT INTO update_likes(update_id, user_id) VALUES(?,?)', u.id, req.user.id);
const it = get('SELECT * FROM items WHERE id = ?', u.item_id);
M.notify([u.user_id], { actorId: req.user.id, boardId: it.board_id, itemId: it.id, kind: 'like', text: `${req.user.name} liked your update on "${it.name}"` });
}
const it = get('SELECT board_id FROM items WHERE id = ?', u.item_id);
M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Files ───────────────────────────────────────────────────────
r.post('/items/:id/files', upload.array('files', 20), wrap((req, res) => {
const it = must(get('SELECT * FROM items WHERE id = ?', req.params.id));
const colId = req.body.column_id || null;
const ids = [];
for (const f of req.files || []) {
const fid = id();
ids.push(fid);
run('INSERT INTO files(id, item_id, column_id, name, stored, mime, size, uploaded_by, created_at) VALUES(?,?,?,?,?,?,?,?,?)',
fid, it.id, colId, f.originalname, f.filename, f.mimetype, f.size, req.user.id, now());
}
if (colId) {
const vals = M.parse(it.vals, {});
applyPatch(it.id, { vals: { [colId]: [...(Array.isArray(vals[colId]) ? vals[colId] : []), ...ids] } }, { userId: req.user.id, origin: origin(req) });
} else {
M.logActivity(it.board_id, it.id, req.user.id, 'file_added', { names: (req.files || []).map((f) => f.originalname) });
M.emitBoard(it.board_id, origin(req));
}
res.json({ ids });
}));
r.delete('/files/:id', wrap((req, res) => {
const f = must(get('SELECT * FROM files WHERE id = ?', req.params.id));
run('DELETE FROM files WHERE id = ?', f.id);
fs.unlink(path.join(UPLOADS, f.stored), () => {});
const it = get('SELECT * FROM items WHERE id = ?', f.item_id);
if (it && f.column_id) {
const vals = M.parse(it.vals, {});
const list = (Array.isArray(vals[f.column_id]) ? vals[f.column_id] : []).filter((x) => x !== f.id);
applyPatch(it.id, { vals: { [f.column_id]: list } }, { userId: req.user.id, origin: origin(req) });
} else if (it) M.emitBoard(it.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Views ───────────────────────────────────────────────────────
r.post('/boards/:id/views', wrap((req, res) => {
const vid = id();
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)',
vid, req.params.id, req.body.type, req.body.name || req.body.type, JSON.stringify(req.body.settings || {}), nextPos('views', 'board_id = ?', req.params.id));
M.emitBoard(req.params.id, origin(req));
res.json({ id: vid });
}));
r.patch('/views/:id', wrap((req, res) => {
const v = must(get('SELECT * FROM views WHERE id = ?', req.params.id));
if ('name' in req.body) run('UPDATE views SET name = ? WHERE id = ?', req.body.name, v.id);
if ('position' in req.body) run('UPDATE views SET position = ? WHERE id = ?', req.body.position, v.id);
if ('settings' in req.body) run('UPDATE views SET settings = ? WHERE id = ?', JSON.stringify(req.body.settings), v.id);
M.emitBoard(v.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/views/:id', wrap((req, res) => {
const v = must(get('SELECT * FROM views WHERE id = ?', req.params.id));
if (get('SELECT COUNT(*) n FROM views WHERE board_id = ?', v.board_id).n <= 1) throw httpErr(400, 'A board needs at least one view');
run('DELETE FROM views WHERE id = ?', v.id);
M.emitBoard(v.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Automations ─────────────────────────────────────────────────
r.post('/boards/:id/automations', wrap((req, res) => {
const aid = id();
run('INSERT INTO automations(id, board_id, trigger_def, action_def, enabled, created_by, created_at) VALUES(?,?,?,?,1,?,?)',
aid, req.params.id, JSON.stringify(req.body.trigger), JSON.stringify(req.body.action), req.user.id, now());
M.emitBoard(req.params.id, origin(req));
res.json({ id: aid });
}));
r.patch('/automations/:id', wrap((req, res) => {
const a = must(get('SELECT * FROM automations WHERE id = ?', req.params.id));
if ('enabled' in req.body) run('UPDATE automations SET enabled = ? WHERE id = ?', req.body.enabled ? 1 : 0, a.id);
if ('trigger' in req.body) run('UPDATE automations SET trigger_def = ? WHERE id = ?', JSON.stringify(req.body.trigger), a.id);
if ('action' in req.body) run('UPDATE automations SET action_def = ? WHERE id = ?', JSON.stringify(req.body.action), a.id);
M.emitBoard(a.board_id, origin(req));
res.json({ ok: true });
}));
r.delete('/automations/:id', wrap((req, res) => {
const a = must(get('SELECT * FROM automations WHERE id = ?', req.params.id));
run('DELETE FROM automations WHERE id = ?', a.id);
M.emitBoard(a.board_id, origin(req));
res.json({ ok: true });
}));
// ─── Notifications / My work / Search ────────────────────────────
r.get('/notifications', wrap((req, res) => {
res.json(all(`SELECT n.*, b.name board_name, i.name item_name FROM notifications n
LEFT JOIN boards b ON b.id = n.board_id LEFT JOIN items i ON i.id = n.item_id
WHERE n.user_id = ? ORDER BY n.created_at DESC LIMIT 200`, req.user.id));
}));
r.post('/notifications/read', wrap((req, res) => {
if (req.body.all) run('UPDATE notifications SET read = 1 WHERE user_id = ?', req.user.id);
else for (const nid of req.body.ids || []) run('UPDATE notifications SET read = ? WHERE id = ? AND user_id = ?', req.body.unread ? 0 : 1, nid, req.user.id);
res.json({ ok: true, unread: get('SELECT COUNT(*) n FROM notifications WHERE user_id = ? AND read = 0', req.user.id).n });
}));
r.delete('/notifications/:id', wrap((req, res) => {
run('DELETE FROM notifications WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
res.json({ ok: true });
}));
// Items where I'm in any people column, with their first date + status for the My Work page.
r.get('/mywork', wrap((req, res) => {
const uid = req.user.id;
const target = req.query.user || uid;
const cols = all("SELECT * FROM columns WHERE type IN ('people','date','timeline','status')").map(M.columnOut);
const byBoard = {};
for (const c of cols) (byBoard[c.board_id] ||= []).push(c);
const rows = all(`SELECT i.*, b.name board_name FROM items i JOIN boards b ON b.id = i.board_id
WHERE i.archived = 0 AND b.archived = 0 AND i.vals LIKE ?`, `%${target}%`);
const out = [];
for (const row of rows) {
const it = M.itemOut(row);
const bc = byBoard[it.board_id] || [];
if (!bc.some((c) => c.type === 'people' && Array.isArray(it.vals[c.id]) && it.vals[c.id].includes(target))) continue;
const dateCol = bc.find((c) => (c.type === 'date' || c.type === 'timeline') && it.vals[c.id]);
const statusCol = bc.find((c) => c.type === 'status' && !/priority/i.test(c.title)) || bc.find((c) => c.type === 'status');
const dv = dateCol ? it.vals[dateCol.id] : null;
const label = statusCol && (statusCol.settings.labels || []).find((l) => l.id === it.vals[statusCol.id]);
const parent = it.parent_id ? get('SELECT name, group_id FROM items WHERE id = ?', it.parent_id) : null;
const group = get('SELECT title, color FROM groups WHERE id = ?', it.group_id || parent?.group_id);
out.push({
id: it.id, name: it.name, board_id: it.board_id, board_name: row.board_name, parent_name: parent?.name || null,
group: group?.title, group_color: group?.color,
date: typeof dv === 'object' && dv ? dv.to || dv.from : dv, date_column: dateCol?.id,
status: label ? { name: label.name, color: label.color, done: !!label.done || /^done$/i.test(label.name) } : null,
status_column: statusCol?.id,
});
}
res.json(out);
}));
r.get('/search', wrap((req, res) => {
const q = String(req.query.q || '').trim().toLowerCase();
if (q.length < 2) return res.json({ boards: [], items: [], updates: [] });
const like = `%${q}%`;
const boards = all('SELECT id, name, workspace_id FROM boards WHERE archived = 0 AND lower(name) LIKE ? LIMIT 20', like);
const items = all(`SELECT i.id, i.name, i.board_id, i.parent_id, b.name board_name, g.title group_title, g.color group_color
FROM items i JOIN boards b ON b.id = i.board_id LEFT JOIN groups g ON g.id = i.group_id
WHERE i.archived = 0 AND b.archived = 0 AND (lower(i.name) LIKE ? OR lower(i.vals) LIKE ?) LIMIT 60`, like, like);
const updates = all(`SELECT u.id, u.body, u.item_id, u.created_at, i.name item_name, i.board_id, b.name board_name
FROM updates u JOIN items i ON i.id = u.item_id JOIN boards b ON b.id = i.board_id
WHERE lower(u.body) LIKE ? ORDER BY u.created_at DESC LIMIT 30`, like);
res.json({ boards, items, updates });
}));
// ─── People / admin ──────────────────────────────────────────────
r.get('/users', wrap((req, res) => res.json(all('SELECT * FROM users ORDER BY name').map((u) => ({ ...publicUser(u), last_seen: u.last_seen })))));
r.patch('/me', wrap((req, res) => {
if ('name' in req.body && req.body.name.trim()) run('UPDATE users SET name = ? WHERE id = ?', req.body.name.trim(), req.user.id);
if ('title' in req.body) run('UPDATE users SET title = ? WHERE id = ?', req.body.title, req.user.id);
res.json(publicUser(get('SELECT * FROM users WHERE id = ?', req.user.id)));
}));
r.post('/users', requireAdmin, wrap((req, res) => {
// Pre-register a teammate so they can be assigned before their first sign-in.
const email = String(req.body.email || '').trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+$/.test(email)) throw httpErr(400, 'Enter a valid email');
if (get('SELECT 1 FROM users WHERE email = ?', email)) throw httpErr(400, 'That person already exists');
run('INSERT INTO users(id, email, name, role, created_at) VALUES(?,?,?,?,?)', id(), email, req.body.name || email.split('@')[0], 'member', now());
res.json({ ok: true });
}));
r.patch('/users/:id', requireAdmin, wrap((req, res) => {
const u = must(get('SELECT * FROM users WHERE id = ?', req.params.id));
if (u.id === req.user.id && ('role' in req.body || 'active' in req.body)) throw httpErr(400, "You can't change your own role or access");
if ('role' in req.body && ['admin', 'member'].includes(req.body.role)) run('UPDATE users SET role = ? WHERE id = ?', req.body.role, u.id);
if ('active' in req.body) {
run('UPDATE users SET active = ? WHERE id = ?', req.body.active ? 1 : 0, u.id);
if (!req.body.active) run('DELETE FROM sessions WHERE user_id = ?', u.id);
}
if ('name' in req.body) run('UPDATE users SET name = ? WHERE id = ?', req.body.name, u.id);
res.json({ ok: true });
}));
// ─── File download (needs a session, so not under express.static) ───
function mountFiles(app) {
app.get('/files/:id/:name?', requireUser, (req, res) => {
const f = get('SELECT * FROM files WHERE id = ?', req.params.id);
if (!f) return res.status(404).end();
const p = path.join(UPLOADS, f.stored);
if (!fs.existsSync(p)) return res.status(404).end();
res.set('Content-Type', f.mime || 'application/octet-stream');
const inline = /^(image|video|audio)\/|application\/pdf|text\/plain/.test(f.mime || '') && req.query.download === undefined;
res.set('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`);
res.set('Cache-Control', 'private, max-age=86400');
fs.createReadStream(p).pipe(res);
});
}
module.exports = { router: r, mountFiles, applyPatch, createColumn, createGroup, defaultViews, httpErr };
+150
View File
@@ -0,0 +1,150 @@
// Sign-in: Google (the normal path, restricted to company domains), plus two fallbacks —
// a password for the admin accounts (so you are never locked out if Google is misconfigured)
// and DEV_LOGIN for local development only.
const crypto = require('crypto');
const { OAuth2Client } = require('google-auth-library');
const { id, now, get, run, all } = require('./db');
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
const ALLOWED_DOMAINS = (process.env.ALLOWED_DOMAINS || 'waterschurch.org')
.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean);
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const DEV_LOGIN = process.env.DEV_LOGIN === '1' && process.env.NODE_ENV !== 'production';
const SESSION_TTL = 60 * 24 * 3600 * 1000;
const COOKIE = 'wb_session';
const google = GOOGLE_CLIENT_ID ? new OAuth2Client(GOOGLE_CLIENT_ID) : null;
function domainAllowed(email) {
const domain = String(email).split('@')[1]?.toLowerCase();
return !!domain && ALLOWED_DOMAINS.includes(domain);
}
function publicUser(u) {
if (!u) return null;
return { id: u.id, email: u.email, name: u.name, avatar: u.avatar, role: u.role, title: u.title, active: !!u.active };
}
// Find-or-create by email. Users created by the monday import (placeholders) are claimed
// here the first time that person signs in, so their assignments carry over.
function upsertUser({ email, name, avatar }) {
email = email.toLowerCase();
let u = get('SELECT * FROM users WHERE email = ?', email);
const isFirst = !get('SELECT 1 FROM users WHERE role = ? AND active = 1 LIMIT 1', 'admin');
if (!u) {
const role = ADMIN_EMAILS.includes(email) || isFirst ? 'admin' : 'member';
run('INSERT INTO users(id, email, name, avatar, role, created_at, last_seen) VALUES(?,?,?,?,?,?,?)',
id(), email, name || email.split('@')[0], avatar || null, role, now(), now());
u = get('SELECT * FROM users WHERE email = ?', email);
} else {
run('UPDATE users SET name = COALESCE(?, name), avatar = COALESCE(?, avatar), last_seen = ? WHERE id = ?',
name || null, avatar || null, now(), u.id);
if (ADMIN_EMAILS.includes(email) && u.role !== 'admin') run('UPDATE users SET role = ? WHERE id = ?', 'admin', u.id);
u = get('SELECT * FROM users WHERE id = ?', u.id);
}
return u;
}
function startSession(res, req, user) {
if (!user.active) throw Object.assign(new Error('This account has been deactivated'), { status: 403 });
const token = crypto.randomBytes(32).toString('base64url');
run('INSERT INTO sessions(token, user_id, expires) VALUES(?,?,?)', token, user.id, now() + SESSION_TTL);
res.cookie(COOKIE, token, {
httpOnly: true, sameSite: 'lax', secure: req.secure, maxAge: SESSION_TTL, path: '/',
});
return token;
}
function sessionUser(req) {
const token = req.cookies?.[COOKIE] || (req.headers.authorization || '').replace(/^Bearer /, '');
if (!token) return null;
const s = get('SELECT * FROM sessions WHERE token = ?', token);
if (!s || s.expires < now()) { if (s) run('DELETE FROM sessions WHERE token = ?', token); return null; }
const u = get('SELECT * FROM users WHERE id = ? AND active = 1', s.user_id);
if (!u) return null;
// Sliding expiry, but only touch the row about once an hour.
if (s.expires - now() < SESSION_TTL - 3600_000) {
run('UPDATE sessions SET expires = ? WHERE token = ?', now() + SESSION_TTL, token);
run('UPDATE users SET last_seen = ? WHERE id = ?', now(), u.id);
}
req.sessionToken = token;
return u;
}
function requireUser(req, res, next) {
const u = sessionUser(req);
if (!u) return res.status(401).json({ error: 'Not signed in' });
req.user = u;
next();
}
function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
next();
}
function timingSafeEqualStr(a, b) {
const ha = crypto.createHash('sha256').update(String(a)).digest();
const hb = crypto.createHash('sha256').update(String(b)).digest();
return crypto.timingSafeEqual(ha, hb);
}
function mount(app) {
app.get('/api/auth/config', (req, res) => {
res.json({
googleClientId: GOOGLE_CLIENT_ID || null,
allowedDomains: ALLOWED_DOMAINS,
passwordLogin: !!ADMIN_PASSWORD,
devLogin: DEV_LOGIN,
});
});
app.post('/api/auth/google', async (req, res) => {
if (!google) return res.status(400).json({ error: 'Google sign-in is not configured on this server' });
try {
const ticket = await google.verifyIdToken({ idToken: req.body.credential, audience: GOOGLE_CLIENT_ID });
const p = ticket.getPayload();
if (!p.email_verified) return res.status(403).json({ error: 'Google has not verified this email address' });
if (!domainAllowed(p.email)) {
return res.status(403).json({ error: `Only ${ALLOWED_DOMAINS.map((d) => '@' + d).join(' / ')} accounts can sign in` });
}
const user = upsertUser({ email: p.email, name: p.name, avatar: p.picture });
startSession(res, req, user);
res.json({ user: publicUser(user) });
} catch (e) {
res.status(e.status || 401).json({ error: e.status ? e.message : 'Google sign-in could not be verified' });
}
});
app.post('/api/auth/password', (req, res) => {
const email = String(req.body.email || '').trim().toLowerCase();
if (!ADMIN_PASSWORD || !ADMIN_EMAILS.includes(email) || !timingSafeEqualStr(req.body.password || '', ADMIN_PASSWORD)) {
return res.status(401).json({ error: 'Incorrect email or password' });
}
const user = upsertUser({ email });
startSession(res, req, user);
res.json({ user: publicUser(user) });
});
app.post('/api/auth/dev', (req, res) => {
if (!DEV_LOGIN) return res.status(404).end();
const email = String(req.body.email || '').trim().toLowerCase();
if (!domainAllowed(email)) return res.status(403).json({ error: 'Email domain not allowed' });
const user = upsertUser({ email, name: req.body.name });
startSession(res, req, user);
res.json({ user: publicUser(user) });
});
app.post('/api/auth/logout', (req, res) => {
const token = req.cookies?.[COOKIE];
if (token) run('DELETE FROM sessions WHERE token = ?', token);
res.clearCookie(COOKIE, { path: '/' });
res.json({ ok: true });
});
// Purge expired sessions occasionally.
setInterval(() => run('DELETE FROM sessions WHERE expires < ?', now()), 6 * 3600_000).unref();
}
module.exports = { mount, requireUser, requireAdmin, sessionUser, publicUser, upsertUser, ALLOWED_DOMAINS, all };
+109
View File
@@ -0,0 +1,109 @@
// Board automations: "When <trigger>, then <action>" recipes.
//
// Triggers: status_change {column_id, to?} — a status column changes (optionally to one label)
// column_change {column_id} — any change to a column
// item_created {}
// date_arrives {column_id} — checked daily at 7am server time
// Actions: move_to_group {group_id} · set_status {column_id, label} · assign {column_id, user_id}
// notify {to: 'people_column'|'user', column_id?, user_id?, text?}
// set_date_today {column_id} · archive {} · clear_column {column_id}
const { all, get, now } = require('./db');
const { parse, itemOut, columnOut, notify, userName } = require('./model');
let applyPatch = null; // injected by api.js to avoid a require cycle
function setPatcher(fn) { applyPatch = fn; }
function matches(trigger, ev) {
if (trigger.type === 'item_created') return ev.type === 'item_created';
if (ev.type !== 'value_changed' || trigger.column_id !== ev.columnId) return false;
if (trigger.type === 'column_change') return true;
if (trigger.type === 'status_change') return !trigger.to || trigger.to === ev.newVal;
return false;
}
function run(ev, depth = 0) {
if (depth > 3 || !applyPatch) return;
const rules = all('SELECT * FROM automations WHERE board_id = ? AND enabled = 1', ev.boardId);
for (const r of rules) {
const trigger = parse(r.trigger_def, {});
if (!matches(trigger, ev)) continue;
try { act(parse(r.action_def, {}), ev.itemId, ev.boardId, depth, trigger); }
catch (e) { console.error('automation failed', r.id, e.message); }
}
}
function act(action, itemId, boardId, depth, trigger) {
const row = get('SELECT * FROM items WHERE id = ?', itemId);
if (!row) return;
const item = itemOut(row);
const board = get('SELECT name FROM boards WHERE id = ?', boardId);
const sys = { userId: null, origin: null, automation: true, depth: depth + 1 };
switch (action.type) {
case 'move_to_group':
if (get('SELECT 1 FROM groups WHERE id = ? AND board_id = ?', action.group_id, boardId)) applyPatch(itemId, { group_id: action.group_id }, sys);
break;
case 'set_status':
applyPatch(itemId, { vals: { [action.column_id]: action.label } }, sys);
break;
case 'assign': {
const cur = Array.isArray(item.vals[action.column_id]) ? item.vals[action.column_id] : [];
if (!cur.includes(action.user_id)) applyPatch(itemId, { vals: { [action.column_id]: [...cur, action.user_id] } }, sys);
break;
}
case 'set_date_today':
applyPatch(itemId, { vals: { [action.column_id]: new Date().toISOString().slice(0, 10) } }, sys);
break;
case 'clear_column':
applyPatch(itemId, { vals: { [action.column_id]: null } }, sys);
break;
case 'archive':
applyPatch(itemId, { archived: true }, sys);
break;
case 'notify': {
let targets = [];
if (action.to === 'user') targets = [action.user_id];
else if (Array.isArray(item.vals[action.column_id])) targets = item.vals[action.column_id];
const text = action.text?.trim() || defaultNotifyText(trigger, item, board);
notify(targets, { actorId: null, boardId, itemId, kind: 'automation', text });
break;
}
}
}
function defaultNotifyText(trigger, item, board) {
if (trigger.type === 'date_arrives') return `"${item.name}" is due today (${board?.name})`;
if (trigger.type === 'item_created') return `New item "${item.name}" was added to ${board?.name}`;
return `"${item.name}" was updated on ${board?.name}`;
}
// Daily sweep for date_arrives triggers.
function dailyDateSweep() {
const today = new Date().toISOString().slice(0, 10);
const rules = all('SELECT * FROM automations WHERE enabled = 1');
for (const r of rules) {
const trigger = parse(r.trigger_def, {});
if (trigger.type !== 'date_arrives') continue;
const items = all('SELECT * FROM items WHERE board_id = ? AND archived = 0', r.board_id).map(itemOut);
for (const it of items) {
const v = it.vals[trigger.column_id];
const d = typeof v === 'object' && v ? v.from : v;
if (typeof d === 'string' && d.slice(0, 10) === today) {
try { act(parse(r.action_def, {}), it.id, r.board_id, 0, trigger); } catch (e) { console.error(e); }
}
}
}
}
function scheduleDaily() {
const { getSetting, setSetting } = require('./db');
setInterval(() => {
const d = new Date();
const key = d.toISOString().slice(0, 10);
if (d.getHours() >= 7 && getSetting('lastDateSweep') !== key) {
setSetting('lastDateSweep', key);
dailyDateSweep();
}
}, 60_000).unref();
}
module.exports = { run, setPatcher, scheduleDaily, describe: { columnOut, userName, now } };
+200
View File
@@ -0,0 +1,200 @@
// SQLite storage via Node's built-in node:sqlite — no native module to compile on the NAS.
// Everything lives in one file under DATA_DIR so a backup is a single copy.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { DatabaseSync } = require('node:sqlite');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
fs.mkdirSync(path.join(DATA_DIR, 'uploads'), { recursive: true });
const db = new DatabaseSync(path.join(DATA_DIR, 'boards.db'));
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
avatar TEXT,
role TEXT NOT NULL DEFAULT 'member', -- admin | member
active INTEGER NOT NULL DEFAULT 1,
title TEXT,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
color TEXT,
position REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT,
position REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
item_label TEXT NOT NULL DEFAULT 'Item',
position REAL NOT NULL DEFAULT 0,
created_by TEXT,
created_at INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS columns (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
type TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
width INTEGER,
position REAL NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
color TEXT NOT NULL,
position REAL NOT NULL DEFAULT 0,
collapsed INTEGER NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
group_id TEXT REFERENCES groups(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
name TEXT NOT NULL,
vals TEXT NOT NULL DEFAULT '{}',
position REAL NOT NULL DEFAULT 0,
created_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
external_id TEXT
);
CREATE INDEX IF NOT EXISTS items_board ON items(board_id);
CREATE INDEX IF NOT EXISTS items_parent ON items(parent_id);
CREATE TABLE IF NOT EXISTS updates (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES updates(id) ON DELETE CASCADE,
user_id TEXT,
author_name TEXT,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
edited_at INTEGER
);
CREATE INDEX IF NOT EXISTS updates_item ON updates(item_id);
CREATE TABLE IF NOT EXISTS update_likes (
update_id TEXT NOT NULL REFERENCES updates(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
PRIMARY KEY (update_id, user_id)
);
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
item_id TEXT REFERENCES items(id) ON DELETE CASCADE,
column_id TEXT,
update_id TEXT,
name TEXT NOT NULL,
stored TEXT NOT NULL,
mime TEXT,
size INTEGER,
uploaded_by TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id TEXT,
item_id TEXT,
user_id TEXT,
action TEXT NOT NULL,
data TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_item ON activity(item_id);
CREATE INDEX IF NOT EXISTS activity_board ON activity(board_id);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id TEXT,
board_id TEXT,
item_id TEXT,
kind TEXT NOT NULL,
text TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS notif_user ON notifications(user_id, read);
CREATE TABLE IF NOT EXISTS favorites (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, board_id)
);
CREATE TABLE IF NOT EXISTS views (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- table | kanban | calendar | timeline | chart | files
name TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
position REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
trigger_def TEXT NOT NULL,
action_def TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_by TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
`);
// Additive migrations for databases created by earlier versions.
for (const sql of ['ALTER TABLE folders ADD COLUMN external_id TEXT']) {
try { db.exec(sql); } catch { /* already applied */ }
}
const id = () => crypto.randomBytes(9).toString('base64url');
const now = () => Date.now();
function all(sql, ...p) { return db.prepare(sql).all(...p); }
function get(sql, ...p) { return db.prepare(sql).get(...p); }
function run(sql, ...p) { return db.prepare(sql).run(...p); }
function tx(fn) {
db.exec('BEGIN');
try { const r = fn(); db.exec('COMMIT'); return r; }
catch (e) { db.exec('ROLLBACK'); throw e; }
}
function getSetting(key, fallback = null) {
const r = get('SELECT value FROM settings WHERE key = ?', key);
return r ? JSON.parse(r.value) : fallback;
}
function setSetting(key, value) {
run('INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', key, JSON.stringify(value));
}
module.exports = { db, DATA_DIR, id, now, all, get, run, tx, getSetting, setSetting };
+410
View File
@@ -0,0 +1,410 @@
// One-time migration from monday.com through its public GraphQL API (api.monday.com/v2),
// using an API token the account owner generates (avatar ▸ Developers ▸ My access tokens).
// It reads data the account already owns — workspaces, folders, boards, columns, groups,
// items, subitems, updates and files — and recreates it here.
//
// Re-running is safe: a board that was already imported (matched by its monday id) is skipped.
const fs = require('fs');
const path = require('path');
const { DATA_DIR, id, now, all, get, run, tx, setSetting } = require('./db');
const { LABEL_COLORS, GROUP_COLORS } = require('./templates');
const M = require('./model');
const API = process.env.MONDAY_API_URL || 'https://api.monday.com/v2';
const UPLOADS = path.join(DATA_DIR, 'uploads');
const status = { running: false, log: [], startedAt: null, finishedAt: null, error: null, counts: {} };
const log = (msg) => { status.log.push(`${new Date().toLocaleTimeString()} ${msg}`); if (status.log.length > 500) status.log.shift(); console.log('[import]', msg); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function gql(token, query, variables = {}, attempt = 0) {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token, 'API-Version': '2024-10' },
body: JSON.stringify({ query, variables }),
});
const body = await res.json().catch(() => ({}));
const errs = body.errors || (body.error_message ? [{ message: body.error_message }] : null);
if (res.status === 429 || (errs && /complexity|rate limit|budget/i.test(JSON.stringify(errs)))) {
if (attempt > 8) throw new Error('monday.com kept rate-limiting the import — try again later');
const wait = Math.min(60, 5 * (attempt + 1));
log(`Rate limited by monday.com, waiting ${wait}s…`);
await sleep(wait * 1000);
return gql(token, query, variables, attempt + 1);
}
if (res.status === 401 || res.status === 403) throw new Error('monday.com rejected the API token');
if (errs) throw new Error(errs.map((e) => e.message).join('; '));
return body.data;
}
const VALUE_FIELDS = 'id type text value';
const ITEM_FIELDS = `
id name created_at updated_at state creator_id group { id }
column_values { ${VALUE_FIELDS} }
assets { id name public_url file_extension file_size }
subitems { id name created_at creator_id column_values { ${VALUE_FIELDS} column { title } } assets { id name public_url file_extension file_size } }
updates(limit: 100) {
id body created_at creator { email name }
assets { id name public_url file_extension file_size }
replies { id body created_at creator { email name } }
}`;
function htmlToText(html) {
return String(html || '')
.replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|h\d)>/gi, '\n').replace(/<li>/gi, '• ')
.replace(/<a [^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, (m, href, t) => (t && t !== href ? `${t} (${href})` : href))
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\n{3,}/g, '\n\n').trim();
}
const parseJSON = (s) => { try { return s ? JSON.parse(s) : null; } catch { return null; } };
// monday column type → { type, settings, convert(columnValue) }
function mapColumn(col, ctx) {
const st = parseJSON(col.settings_str) || {};
const t = col.type;
if (t === 'status' || t === 'color') {
let labels = [];
if (Array.isArray(st.labels)) {
labels = st.labels.map((l) => ({ id: 'm' + l.id, name: l.name ?? l.label ?? '', color: l.color || st.labels_colors?.[l.id]?.color }));
} else {
labels = Object.entries(st.labels || {}).map(([k, name]) => ({ id: 'm' + k, name, color: st.labels_colors?.[k]?.color }));
}
const done = new Set((st.done_colors || []).map((d) => 'm' + d));
labels = labels.filter((l) => l.name !== '' && l.name != null).map((l, i) => ({
...l, color: l.color || LABEL_COLORS[i % LABEL_COLORS.length], ...(done.has(l.id) || /^done$/i.test(l.name) ? { done: true } : {}),
}));
return { type: 'status', settings: { labels }, convert: (cv) => { const v = parseJSON(cv.value); return v && v.index != null ? 'm' + v.index : null; } };
}
if (t === 'dropdown') {
const labels = (st.labels || []).map((l, i) => ({ id: 'm' + l.id, name: l.name, color: LABEL_COLORS[i % LABEL_COLORS.length] }));
return { type: 'dropdown', settings: { labels }, convert: (cv) => { const v = parseJSON(cv.value); return v?.ids?.length ? v.ids.map((x) => 'm' + x) : null; } };
}
if (t === 'people' || t === 'multiple-person' || t === 'person') {
return { type: 'people', settings: {}, convert: (cv) => {
const v = parseJSON(cv.value);
const ids = (v?.personsAndTeams || []).filter((p) => p.kind !== 'team').map((p) => ctx.userMap[p.id]).filter(Boolean);
return ids.length ? ids : null;
} };
}
if (t === 'date') {
return { type: 'date', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.date ? (v.time ? `${v.date}T${v.time.slice(0, 5)}` : v.date) : null; } };
}
if (t === 'timeline' || t === 'timerange') {
return { type: 'timeline', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.from ? { from: v.from, to: v.to || v.from } : null; } };
}
if (t === 'numbers' || t === 'numeric') {
return { type: 'number', settings: { unit: st.unit?.symbol || '' }, convert: (cv) => (cv.text !== '' && cv.text != null && Number.isFinite(Number(cv.text)) ? Number(cv.text) : null) };
}
if (t === 'checkbox' || t === 'boolean') return { type: 'checkbox', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.checked === true || v?.checked === 'true' ? true : null; } };
if (t === 'link') return { type: 'link', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.url ? { url: v.url, text: v.text || '' } : null; } };
if (t === 'email') return { type: 'email', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.email || cv.text || null; } };
if (t === 'phone') return { type: 'phone', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return v?.phone || cv.text || null; } };
if (t === 'rating') return { type: 'rating', settings: { max: st.max || 5 }, convert: (cv) => { const v = parseJSON(cv.value); return v?.rating || null; } };
if (t === 'long_text' || t === 'long-text') return { type: 'long_text', settings: {}, convert: (cv) => cv.text || null };
if (t === 'file' || t === 'files') return { type: 'files', settings: {}, convert: (cv) => { const v = parseJSON(cv.value); return (v?.files || []).map((f) => f.assetId).filter(Boolean); }, isFiles: true };
if (t === 'tags') return { type: 'tags', settings: {}, convert: (cv) => (cv.text ? cv.text.split(',').map((s) => s.trim()).filter(Boolean) : null) };
if (t === 'creation_log' || t === 'pulse-log') return { type: 'created', settings: {}, convert: () => null };
if (t === 'last_updated' || t === 'pulse-updated') return { type: 'updated', settings: {}, convert: () => null };
if (t === 'item_id' || t === 'pulse-id') return { type: 'item_id', settings: {}, convert: () => null };
// Everything else (mirror, formula, connect boards, location, hour, …) keeps its text.
return { type: 'text', settings: { importedFrom: t }, convert: (cv) => cv.text || null };
}
const SKIP_TYPES = new Set(['name', 'subtasks', 'subitems', 'button', 'doc', 'direct_doc']);
async function downloadAsset(asset, itemId, columnId, updateId, uploadedBy) {
const existing = get('SELECT id FROM files WHERE stored LIKE ?', `monday-${asset.id}.%`);
if (existing) return existing.id;
if (!asset.public_url) return null;
try {
const res = await fetch(asset.public_url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const ext = (asset.file_extension || path.extname(asset.name) || '').replace(/^\.?/, '.').slice(0, 12);
const stored = `monday-${asset.id}${ext}`;
fs.writeFileSync(path.join(UPLOADS, stored), buf);
const fid = id();
run('INSERT INTO files(id, item_id, column_id, update_id, name, stored, mime, size, uploaded_by, created_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
fid, itemId, columnId, updateId, asset.name, stored, mimeFor(ext), buf.length, uploadedBy, now());
status.counts.files = (status.counts.files || 0) + 1;
return fid;
} catch (e) {
log(` could not download "${asset.name}": ${e.message}`);
return null;
}
}
function mimeFor(ext) {
const m = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.txt': 'text/plain', '.csv': 'text/csv',
'.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.svg': 'image/svg+xml' };
return m[String(ext).toLowerCase()] || 'application/octet-stream';
}
async function importAll(token, opts = {}) {
if (status.running) throw new Error('An import is already running');
Object.assign(status, { running: true, log: [], startedAt: now(), finishedAt: null, error: null, counts: {} });
const ctx = { userMap: {}, emailById: {} };
try {
const me = await gql(token, '{ me { name email account { name } } }');
log(`Connected to monday.com account "${me.me.account?.name}" as ${me.me.name}`);
// People: placeholders by email, claimed when that person first signs in with Google.
const { users } = await gql(token, '{ users(limit: 1000) { id name email photo_thumb_small enabled is_guest } }');
for (const u of users) {
if (!u.email) continue;
const email = u.email.toLowerCase();
let row = get('SELECT id FROM users WHERE email = ?', email);
if (!row) {
const uid = id();
run('INSERT INTO users(id, email, name, avatar, role, active, created_at) VALUES(?,?,?,?,?,?,?)',
uid, email, u.name, u.photo_thumb_small || null, 'member', u.enabled === false ? 0 : 1, now());
row = { id: uid };
}
ctx.userMap[u.id] = row.id;
}
log(`Matched ${users.length} people`);
// Workspaces
const { workspaces } = await gql(token, '{ workspaces(limit: 100) { id name description } }');
const wsMap = {};
for (const w of workspaces) {
let row = get('SELECT id FROM workspaces WHERE name = ?', w.name);
if (!row) {
const wid = id();
run('INSERT INTO workspaces(id, name, description, color, position, created_at) VALUES(?,?,?,?,?,?)',
wid, w.name, w.description || '', GROUP_COLORS[Object.keys(wsMap).length % GROUP_COLORS.length], Object.keys(wsMap).length + 1, now());
row = { id: wid };
}
wsMap[w.id] = row.id;
}
const mainWs = () => {
if (!wsMap.main) {
let row = get('SELECT id FROM workspaces WHERE name = ?', 'Main workspace');
if (!row) { row = { id: id() }; run('INSERT INTO workspaces(id, name, position, created_at) VALUES(?,?,?,?)', row.id, 'Main workspace', 0, now()); }
wsMap.main = row.id;
}
return wsMap.main;
};
log(`${workspaces.length} workspaces`);
// Folders (two passes so parents exist before children)
const folderMap = {};
const allFolders = [];
const wsIds = workspaces.map((w) => Number(w.id));
for (let page = 1; page < 50; page++) {
const d = await gql(token, `query($ws: [ID], $page: Int) { folders(limit: 100, page: $page, workspace_ids: $ws) { id name color parent { id } workspace { id } } }`, { ws: wsIds, page });
allFolders.push(...d.folders);
if (d.folders.length < 100) break;
}
// Reuse folders from an earlier run (matched by monday id) so re-running doesn't duplicate them.
for (const [i, f] of allFolders.entries()) {
const existing = get('SELECT id FROM folders WHERE external_id = ?', 'monday:' + f.id);
if (existing) { folderMap[f.id] = existing.id; continue; }
const fid = id();
folderMap[f.id] = fid;
run('INSERT INTO folders(id, workspace_id, parent_id, name, color, position, external_id) VALUES(?,?,?,?,?,?,?)',
fid, f.workspace ? wsMap[f.workspace.id] || mainWs() : mainWs(), null, f.name, null, i + 1, 'monday:' + f.id);
}
for (const f of allFolders) if (f.parent && folderMap[f.parent.id]) run('UPDATE folders SET parent_id = ? WHERE id = ?', folderMap[f.parent.id], folderMap[f.id]);
log(`${allFolders.length} folders`);
// Boards
const boards = [];
for (let page = 1; page < 100; page++) {
const d = await gql(token, `query($page: Int) { boards(limit: 50, page: $page, state: active) {
id name description type workspace_id board_folder_id item_terminology
columns { id title type settings_str width }
groups { id title color position archived }
} }`, { page });
boards.push(...d.boards.filter((b) => (b.type || 'board') === 'board'));
if (d.boards.length < 50) break;
}
const wanted = opts.boardIds?.length ? boards.filter((b) => opts.boardIds.includes(String(b.id))) : boards;
log(`${wanted.length} boards to import`);
for (const [bi, b] of wanted.entries()) {
if (get('SELECT 1 FROM boards WHERE external_id = ?', 'monday:' + b.id)) { log(`Skipping "${b.name}" (already imported)`); continue; }
log(`(${bi + 1}/${wanted.length}) ${b.name}`);
await importBoard(token, b, ctx, {
workspaceId: b.workspace_id ? wsMap[b.workspace_id] || mainWs() : mainWs(),
folderId: b.board_folder_id ? folderMap[b.board_folder_id] || null : null,
position: bi + 1,
});
status.counts.boards = (status.counts.boards || 0) + 1;
M.emitTree(null);
}
// Tidy: drop imported folders left empty (e.g. only held boards you chose not to import).
for (let changed = true; changed;) {
changed = false;
for (const fid of Object.values(folderMap)) {
if (!get('SELECT 1 FROM folders WHERE id = ?', fid)) continue;
const hasContent = get('SELECT 1 FROM boards WHERE folder_id = ? UNION SELECT 1 FROM folders WHERE parent_id = ? LIMIT 1', fid, fid);
if (!hasContent) { run('DELETE FROM folders WHERE id = ?', fid); changed = true; }
}
}
setSetting('mondayImport', { at: now(), counts: status.counts });
log(`Done — ${status.counts.boards || 0} boards, ${status.counts.items || 0} items, ${status.counts.updates || 0} updates, ${status.counts.files || 0} files`);
} catch (e) {
status.error = e.message;
log(`FAILED: ${e.message}`);
} finally {
status.running = false;
status.finishedAt = now();
M.emitTree(null);
}
}
async function importBoard(token, b, ctx, where) {
const bid = id();
const colInfo = {}; // monday column id → { ourId, map }
tx(() => {
run('INSERT INTO boards(id, workspace_id, folder_id, name, description, item_label, position, created_at, external_id) VALUES(?,?,?,?,?,?,?,?,?)',
bid, where.workspaceId, where.folderId, b.name, b.description || '', b.item_terminology || 'Item', where.position, now(), 'monday:' + b.id);
let pos = 1;
for (const c of b.columns) {
if (SKIP_TYPES.has(c.type)) continue;
const map = mapColumn(c, ctx);
const cid = id();
run('INSERT INTO columns(id, board_id, title, type, settings, width, position, external_id) VALUES(?,?,?,?,?,?,?,?)',
cid, bid, c.title, map.type, JSON.stringify(map.settings), c.width || null, pos++, c.id);
colInfo[c.id] = { ourId: cid, map, title: c.title };
}
b.groups.filter((g) => !g.archived).forEach((g, i) => {
run('INSERT INTO groups(id, board_id, title, color, position, external_id) VALUES(?,?,?,?,?,?)',
id(), bid, g.title, g.color || GROUP_COLORS[i % GROUP_COLORS.length], Number(g.position) || i + 1, g.id);
});
run('INSERT INTO views(id, board_id, type, name, settings, position) VALUES(?,?,?,?,?,?)', id(), bid, 'table', 'Main table', '{}', 0);
});
const groupMap = Object.fromEntries(all('SELECT id, external_id FROM groups WHERE board_id = ?', bid).map((g) => [g.external_id, g.id]));
// Pages of items
let cursor = null;
let position = 0;
let first = true;
while (first || cursor) {
const d = first
? await gql(token, `query($id: [ID!]) { boards(ids: $id) { items_page(limit: 25) { cursor items { ${ITEM_FIELDS} } } } }`, { id: [b.id] })
: await gql(token, `query($c: String!) { next_items_page(limit: 25, cursor: $c) { cursor items { ${ITEM_FIELDS} } } }`, { c: cursor });
const page = first ? d.boards[0].items_page : d.next_items_page;
first = false;
cursor = page.cursor;
for (const it of page.items) {
await importItem(it, { bid, groupMap, colInfo, ctx, position: ++position });
}
}
}
function valuesFor(columnValues, colInfo) {
const vals = {};
const assetCols = [];
const unmatched = [];
for (const cv of columnValues || []) {
const info = colInfo[cv.id];
if (!info) { if (cv.text) unmatched.push(cv); continue; }
const v = info.map.convert(cv);
if (info.map.isFiles) { if (v?.length) assetCols.push({ col: info.ourId, assetIds: v.map(String) }); continue; }
if (v !== null && v !== undefined && !(Array.isArray(v) && !v.length)) vals[info.ourId] = v;
}
return { vals, assetCols, unmatched };
}
async function importItem(it, { bid, groupMap, colInfo, ctx, position }) {
const iid = id();
const createdAt = Date.parse(it.created_at) || now();
const createdBy = ctx.userMap[it.creator_id] || null;
const { vals, assetCols } = valuesFor(it.column_values, colInfo);
run('INSERT INTO items(id, board_id, group_id, name, vals, position, created_by, created_at, updated_at, archived, external_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
iid, bid, groupMap[it.group?.id] || Object.values(groupMap)[0], it.name, JSON.stringify(vals), position, createdBy,
createdAt, Date.parse(it.updated_at) || createdAt, it.state === 'archived' ? 1 : 0, 'monday:' + it.id);
status.counts.items = (status.counts.items || 0) + 1;
// Files that belong to a file column
const assetsById = Object.fromEntries((it.assets || []).map((a) => [String(a.id), a]));
const used = new Set();
for (const { col, assetIds } of assetCols) {
const ids = [];
for (const aid of assetIds) {
used.add(aid);
const asset = assetsById[aid];
if (!asset) continue;
const fid = await downloadAsset(asset, iid, col, null, createdBy);
if (fid) ids.push(fid);
}
if (ids.length) { vals[col] = ids; }
}
if (assetCols.length) run('UPDATE items SET vals = ? WHERE id = ?', JSON.stringify(vals), iid);
// Subitems: monday gives them their own columns. A value lands in the parent board's
// column with the same title and type; anything without a match is kept as a note.
const byTitle = {};
for (const info of Object.values(colInfo)) byTitle[info.title.toLowerCase()] = info;
for (const [si, s] of (it.subitems || []).entries()) {
const sid = id();
const svals = {};
const notes = [];
for (const cv of s.column_values || []) {
if (SKIP_TYPES.has(cv.type) || !cv.text) continue;
const title = cv.column?.title || cv.id;
const info = byTitle[title.toLowerCase()];
const v = info && !info.map.isFiles ? subitemValue(info, cv, ctx) : null;
if (v !== null && v !== undefined) svals[info.ourId] = v;
else notes.push(`${title}: ${cv.text}`);
}
run('INSERT INTO items(id, board_id, parent_id, name, vals, position, created_by, created_at, updated_at, external_id) VALUES(?,?,?,?,?,?,?,?,?,?)',
sid, bid, iid, s.name, JSON.stringify(svals), si + 1, ctx.userMap[s.creator_id] || null, Date.parse(s.created_at) || now(), now(), 'monday:' + s.id);
if (notes.length) {
run('INSERT INTO updates(id, item_id, author_name, body, created_at) VALUES(?,?,?,?,?)', id(), sid, 'monday.com import', `Imported values:\n${notes.join('\n')}`, now());
}
for (const a of s.assets || []) await downloadAsset(a, sid, null, null, null);
status.counts.items++;
}
// Updates and replies, oldest thread first is irrelevant — they're sorted by date on display.
for (const u of it.updates || []) {
const uid = id();
const author = u.creator?.email ? get('SELECT id FROM users WHERE email = ?', u.creator.email.toLowerCase())?.id : null;
run('INSERT INTO updates(id, item_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?)',
uid, iid, author, u.creator?.name || null, htmlToText(u.body), Date.parse(u.created_at) || now());
status.counts.updates = (status.counts.updates || 0) + 1;
for (const a of u.assets || []) { used.add(String(a.id)); await downloadAsset(a, iid, null, uid, author); }
for (const rep of u.replies || []) {
const ra = rep.creator?.email ? get('SELECT id FROM users WHERE email = ?', rep.creator.email.toLowerCase())?.id : null;
run('INSERT INTO updates(id, item_id, parent_id, user_id, author_name, body, created_at) VALUES(?,?,?,?,?,?,?)',
id(), iid, uid, ra, rep.creator?.name || null, htmlToText(rep.body), Date.parse(rep.created_at) || now());
}
}
// Any remaining files on the item (attached outside a file column or update).
for (const a of it.assets || []) if (!used.has(String(a.id))) await downloadAsset(a, iid, null, null, createdBy);
}
// Subitem status/dropdown indexes refer to the subitem board's labels, so match by label name.
function subitemValue(info, cv, ctx) {
const t = info.map.type;
if (t === 'status' || t === 'dropdown') {
const names = t === 'status' ? [cv.text] : cv.text.split(',').map((x) => x.trim());
const ids = names.map((n) => (info.map.settings.labels || []).find((l) => l.name.toLowerCase() === n.toLowerCase())?.id).filter(Boolean);
if (!ids.length) return null;
return t === 'status' ? ids[0] : ids;
}
const subMap = mapColumn({ type: cv.type, settings_str: null }, ctx);
if (subMap.type !== t) return null;
return subMap.convert(cv);
}
async function listBoards(token) {
const out = [];
for (let page = 1; page < 100; page++) {
const d = await gql(token, 'query($page: Int) { boards(limit: 100, page: $page, state: active) { id name type items_count workspace { name } } }', { page });
out.push(...d.boards.filter((b) => (b.type || 'board') === 'board'));
if (d.boards.length < 100) break;
}
return out.map((b) => ({ id: b.id, name: b.name, workspace: b.workspace?.name || 'Main workspace', items: b.items_count,
imported: !!get('SELECT 1 FROM boards WHERE external_id = ?', 'monday:' + b.id) }));
}
module.exports = { importAll, listBoards, status, htmlToText, mapColumn };
+85
View File
@@ -0,0 +1,85 @@
// Shared domain helpers: loading boards, value formatting, activity, notifications.
const { id, now, all, get, run } = require('./db');
const { emit } = require('./realtime');
const parse = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } };
function itemOut(r) {
return {
id: r.id, board_id: r.board_id, group_id: r.group_id, parent_id: r.parent_id, name: r.name,
vals: parse(r.vals, {}), position: r.position, created_by: r.created_by,
created_at: r.created_at, updated_at: r.updated_at,
};
}
function columnOut(c) {
return { id: c.id, board_id: c.board_id, title: c.title, type: c.type, settings: parse(c.settings, {}), width: c.width, position: c.position };
}
function loadBoard(boardId) {
const board = get('SELECT * FROM boards WHERE id = ? AND archived = 0', boardId);
if (!board) return null;
const columns = all('SELECT * FROM columns WHERE board_id = ? ORDER BY position', boardId).map(columnOut);
const groups = all('SELECT * FROM groups WHERE board_id = ? ORDER BY position', boardId)
.map((g) => ({ ...g, collapsed: !!g.collapsed }));
const items = all('SELECT * FROM items WHERE board_id = ? AND archived = 0 ORDER BY position', boardId).map(itemOut);
const views = all('SELECT * FROM views WHERE board_id = ? ORDER BY position', boardId)
.map((v) => ({ ...v, settings: parse(v.settings, {}) }));
const files = all(`SELECT f.id, f.item_id, f.column_id, f.name, f.mime, f.size, f.created_at FROM files f
JOIN items i ON i.id = f.item_id WHERE i.board_id = ? AND f.update_id IS NULL`, boardId);
const counts = all(`SELECT u.item_id, COUNT(*) n FROM updates u JOIN items i ON i.id = u.item_id
WHERE i.board_id = ? GROUP BY u.item_id`, boardId);
const updateCounts = Object.fromEntries(counts.map((c) => [c.item_id, c.n]));
const automations = all('SELECT * FROM automations WHERE board_id = ? ORDER BY created_at', boardId)
.map((a) => ({ ...a, trigger: parse(a.trigger_def, {}), action: parse(a.action_def, {}), enabled: !!a.enabled }));
return { board, columns, groups, items, views, files, updateCounts, automations };
}
function userName(uid) {
return get('SELECT name FROM users WHERE id = ?', uid)?.name || 'Someone';
}
// Human-readable rendering of a stored value — used for activity, search, CSV and notifications.
function formatValue(col, v) {
if (v === undefined || v === null || v === '') return '';
const s = col.settings || {};
switch (col.type) {
case 'status': return (s.labels || []).find((l) => l.id === v)?.name || '';
case 'dropdown': return (Array.isArray(v) ? v : []).map((x) => (s.labels || []).find((l) => l.id === x)?.name).filter(Boolean).join(', ');
case 'people': return (Array.isArray(v) ? v : []).map(userName).join(', ');
case 'timeline': return v.from ? `${v.from} – ${v.to || v.from}` : '';
case 'checkbox': return v ? '✓' : '';
case 'link': return typeof v === 'object' ? (v.text || v.url || '') : String(v);
case 'tags': return (Array.isArray(v) ? v : []).join(', ');
case 'files': return Array.isArray(v) ? `${v.length} file(s)` : '';
case 'rating': return v ? '★'.repeat(v) : '';
default: return String(v);
}
}
function logActivity(boardId, itemId, userId, action, data) {
run('INSERT INTO activity(board_id, item_id, user_id, action, data, created_at) VALUES(?,?,?,?,?,?)',
boardId, itemId, userId, action, data ? JSON.stringify(data) : null, now());
}
function notify(userIds, { actorId, boardId, itemId, kind, text }) {
const seen = new Set();
for (const uid of userIds) {
if (!uid || uid === actorId || seen.has(uid)) continue;
seen.add(uid);
if (!get('SELECT 1 FROM users WHERE id = ? AND active = 1', uid)) continue;
run('INSERT INTO notifications(id, user_id, actor_id, board_id, item_id, kind, text, created_at) VALUES(?,?,?,?,?,?,?,?)',
id(), uid, actorId || null, boardId || null, itemId || null, kind, text, now());
emit({ type: 'notification' }, { userId: uid });
}
}
function emitBoard(boardId, origin) { emit({ type: 'board', boardId }, { origin }); }
function emitTree(origin) { emit({ type: 'tree' }, { origin }); }
function peopleOnItem(item, columns) {
const ids = [];
for (const c of columns) if (c.type === 'people' && Array.isArray(item.vals[c.id])) ids.push(...item.vals[c.id]);
return ids;
}
module.exports = { parse, itemOut, columnOut, loadBoard, formatValue, logActivity, notify, emitBoard, emitTree, userName, peopleOnItem };
+37
View File
@@ -0,0 +1,37 @@
// Server-sent events: every open tab holds one stream. Changes are broadcast as small
// "something changed" hints and the client refetches what it is looking at — simpler and
// more robust than shipping patches, and plenty fast for an internal team.
const clients = new Set();
function mount(app, requireUser) {
app.get('/api/events', requireUser, (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
res.write('retry: 3000\n\n');
const client = { res, userId: req.user.id, clientId: String(req.query.client || '') };
clients.add(client);
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
req.on('close', () => { clearInterval(ping); clients.delete(client); });
});
}
// opts.userId limits delivery to one user; opts.origin is the clientId that caused the
// change, which the client uses to skip refetching its own edits.
function emit(event, opts = {}) {
const payload = `data: ${JSON.stringify({ ...event, origin: opts.origin || null })}\n\n`;
for (const c of clients) {
if (opts.userId && c.userId !== opts.userId) continue;
try { c.res.write(payload); } catch { /* closed */ }
}
}
function onlineUserIds() {
return [...new Set([...clients].map((c) => c.userId))];
}
module.exports = { mount, emit, onlineUserIds };
+69
View File
@@ -0,0 +1,69 @@
// Default column settings and the starter templates offered when creating a board.
const GROUP_COLORS = ['#579bfc', '#00c875', '#fdab3d', '#e2445c', '#a25ddc', '#037f4c', '#ff642e', '#66ccff', '#ff158a', '#9cd326', '#784bd1', '#cab641', '#bb3354', '#175a63', '#ff5ac4', '#7f5347'];
const LABEL_COLORS = ['#00c875', '#fdab3d', '#df2f4a', '#579bfc', '#a25ddc', '#c4c4c4', '#037f4c', '#ff642e', '#9cd326', '#ffcb00', '#bb3354', '#784bd1', '#66ccff', '#ff158a', '#7f5347', '#225091', '#175a63', '#cab641', '#ff7575', '#fdbc64'];
function statusSettings(kind = 'status') {
if (kind === 'priority') {
return { labels: [
{ id: 'p1', name: 'Critical ⚠️', color: '#333333' },
{ id: 'p2', name: 'High', color: '#401694' },
{ id: 'p3', name: 'Medium', color: '#5559df' },
{ id: 'p4', name: 'Low', color: '#579bfc' },
] };
}
return {
labels: [
{ id: 's1', name: 'Working on it', color: '#fdab3d' },
{ id: 's2', name: 'Done', color: '#00c875', done: true },
{ id: 's3', name: 'Stuck', color: '#df2f4a' },
],
};
}
function defaultSettings(type) {
switch (type) {
case 'status': return statusSettings();
case 'priority': return statusSettings('priority');
case 'dropdown': return { labels: [] };
case 'number': return { unit: '', decimals: null };
case 'rating': return { max: 5 };
default: return {};
}
}
const COLUMN_TYPES = ['text', 'long_text', 'status', 'people', 'date', 'timeline', 'number', 'checkbox',
'dropdown', 'link', 'email', 'phone', 'rating', 'files', 'tags', 'created', 'updated', 'item_id'];
const TEMPLATES = {
blank: {
label: 'Blank board',
columns: [['Owner', 'people'], ['Status', 'status'], ['Due date', 'date']],
groups: ['Group Title'],
},
project: {
label: 'Project tracker',
columns: [['Owner', 'people'], ['Status', 'status'], ['Priority', 'priority'], ['Timeline', 'timeline'], ['Due date', 'date'], ['Notes', 'text']],
groups: ['This week', 'Next week', 'Backlog'],
items: { 0: ['Kick-off meeting', 'Plan the schedule'], 2: ['Ideas'] },
},
inventory: {
label: 'Equipment inventory',
itemLabel: 'Item',
columns: [['Model', 'text'], ['Serial #', 'text'], ['Location', 'dropdown'], ['Condition', 'status'], ['Purchase date', 'date'], ['Cost', 'number'], ['Notes', 'long_text'], ['Files', 'files']],
groups: ['Audio', 'Video', 'Lighting'],
},
content: {
label: 'Content calendar',
columns: [['Owner', 'people'], ['Status', 'status'], ['Publish date', 'date'], ['Channel', 'dropdown'], ['Caption', 'long_text'], ['Files', 'files']],
groups: ['Planned', 'In progress', 'Published'],
},
requests: {
label: 'Requests',
itemLabel: 'Request',
columns: [['Requested by', 'people'], ['Assigned', 'people'], ['Status', 'status'], ['Priority', 'priority'], ['Needed by', 'date'], ['Details', 'long_text']],
groups: ['New requests', 'In progress', 'Completed'],
},
};
module.exports = { GROUP_COLORS, LABEL_COLORS, COLUMN_TYPES, TEMPLATES, defaultSettings };