Compare commits

..
2 Commits
Author SHA1 Message Date
x3 0bb4e5c1cd feat(frontend): bell, chat, uploads, glass, teams UI, fixes
ci / frontend (push) Canceled after 0s
ci / backend (push) Canceled after 0s
- Notification bell with invites (accept/decline refreshes workspaces)
  and unread badge (polling + socket push); team chat view realtime
- Avatar/background uploads in settings + glassmorphism widgets toggle;
  background layer resolves /uploads URLs; full-bleed blurred backdrop
- Sidebar: visible creation errors, working sub-pages (parent_id),
  TEAM badge workspaces, refresh button, chat nav; admin creates users
- Tasks: per-team boards, assignee select with avatars; admin tabs for
  users/teams/groups (team workspaces, permission checkboxes)
- Register view removed (admin-only accounts); login localized;
  prettier emoji set; scaling/overflow + mobile fixes
2026-09-22 14:27:33 +02:00
x3 c3c77333db feat(backend): invites, notifications, chat, uploads, teams, groups, hardening
- Pending workspace invites with accept/decline + bell notifications;
  invite permission extendable via workspace.invite group right
- Notifications API (list/unread, mark read, read-all) + realtime push
  to per-user socket rooms; chat messages notify other members
- Workspace team chat API (list/post/delete) + chat:join socket rooms
- Image uploads (multer, 5MB image-only) served from /uploads
- Teams (managers/members), team-owned workspaces (TEAM badge), team
  task boards (team_id) and task assignees (workspace members only)
- Permission groups with teams.create/edit/invite + workspace.invite,
  default Team Creators seeding, hasPerm/canManageTeam guards
- Public registration disabled after install; admins create users
  (pending invites auto-link); users gain bg_glass setting
- Crash fix: express-async-errors + 400 on malformed IDs instead of
  unhandled rejections killing the server
2026-09-22 14:27:33 +02:00
40 changed files with 1832 additions and 193 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
uploads/
.env
*.log
.DS_Store
+8 -1
View File
@@ -15,7 +15,14 @@ light/dark mode. Self-hostable via Node.js or Docker Compose.
- **Pages** — Nested pages (`parent_id`) in workspaces or **books**, icon + cover,
ordering, TipTap rich-text with **tables + checklists**, stored as JSON + HTML.
- **Books** 📚 — Independent containers for pages, collapsible in the sidebar.
- **Tasks** ✅ — Kanban (todo/doing/done), priorities, deadlines, links to books/pages.
- **Tasks** ✅ — Kanban (todo/doing/doing→done), priorities, deadlines, **assignees**,
links to books/pages, **per-team boards**.
- **Teams** 👥 — Teams with managers/members, **own workspaces** (`TEAM_"name"` badge),
permission **groups** (e.g. Team Creators: teams.create/edit/invite, workspace.invite).
- **Invites + notifications** 🔔 — Pending invites with accept/decline, bell with unread
badge (polling + realtime push), notifications for invites and chat messages.
- **Team chat** 💬 — Realtime workspace channel via Socket.IO.
- **Uploads** 📤 — Avatar/background images uploadable (served from `/uploads`).
- **Calendar** 🗓️ — Deadlines from tasks appear automatically.
- **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list.
- **Settings window** ⚙️ — Profile (name, email, avatar, password), appearance
+163
View File
@@ -12,10 +12,12 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0",
"socket.io": "^4.7.5"
}
@@ -66,6 +68,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -135,6 +143,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -173,6 +198,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -209,6 +249,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -447,6 +493,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-async-errors": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
"license": "ISC",
"peerDependencies": {
"express": "^4.16.2"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
@@ -636,6 +691,12 @@
"node": ">= 0.10"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -796,6 +857,27 @@
"node": ">= 0.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/morgan": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz",
@@ -822,6 +904,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1016,6 +1117,12 @@
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1073,6 +1180,27 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1362,6 +1490,29 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1384,6 +1535,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@@ -1399,6 +1556,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+2
View File
@@ -15,10 +15,12 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0",
"socket.io": "^4.7.5"
}
+79
View File
@@ -133,6 +133,85 @@ CREATE TABLE IF NOT EXISTS time_entries (
);
CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id);
-- Pending workspace invites (accept/decline via notification bell)
CREATE TABLE IF NOT EXISTS workspace_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin','member','viewer')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','declined')),
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_invites_email ON workspace_invites(email);
CREATE INDEX IF NOT EXISTS idx_invites_user ON workspace_invites(user_id);
-- Notifications (invites, chat mentions/messages, etc.)
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
link TEXT NOT NULL DEFAULT '',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, is_read);
-- Workspace team chat
CREATE TABLE IF NOT EXISTS chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_chat_ws ON chat_messages(workspace_id, created_at);
-- Teams inside a workspace
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
icon TEXT NOT NULL DEFAULT '👥',
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Workspace optionally owned by a team (shows TEAM_"name" badge in UI)
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
-- Tasks: assignee (user) + owning team (team boards)
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_id UUID REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks(team_id);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_id);
-- Glassmorphism widgets over background image
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_glass BOOLEAN NOT NULL DEFAULT TRUE;
CREATE TABLE IF NOT EXISTS team_members (
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('manager','member')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id)
);
-- Permission groups (e.g. "Team Creators" with teams.create / teams.edit / teams.invite)
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
permissions TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, name)
);
CREATE TABLE IF NOT EXISTS group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, user_id)
);
-- Ensure workspace owner is always a member with role=owner
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
BEGIN
+16 -1
View File
@@ -1,4 +1,5 @@
import 'dotenv/config';
import 'express-async-errors';
import http from 'node:http';
import express from 'express';
import cors from 'cors';
@@ -15,6 +16,12 @@ import bookRoutes from './routes/books.js';
import taskRoutes from './routes/tasks.js';
import timeRoutes from './routes/time.js';
import adminRoutes from './routes/admin.js';
import inviteRoutes from './routes/invites.js';
import notificationRoutes from './routes/notifications.js';
import chatRoutes from './routes/chat.js';
import teamRoutes from './routes/teams.js';
import groupRoutes from './routes/groups.js';
import uploadRoutes from './routes/upload.js';
import { initSocket } from './socket.js';
const app = express();
@@ -51,6 +58,13 @@ app.use('/api/books', bookRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/time', timeRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/invites', inviteRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/chat', chatRoutes);
app.use('/api/teams', teamRoutes);
app.use('/api/groups', groupRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/uploads', express.static('uploads'));
app.get('/api/health', async (_req, res) => {
try {
@@ -62,7 +76,8 @@ app.get('/api/health', async (_req, res) => {
});
app.use((err, _req, res, _next) => {
console.error('[unhandled]', err);
console.error('[unhandled]', err?.message || err);
if (err?.code === '22P02') return res.status(400).json({ error: 'Invalid ID format' });
res.status(500).json({ error: 'Internal server error' });
});
+32
View File
@@ -34,6 +34,38 @@ router.get('/users', async (_req, res) => {
return res.json({ users: rows });
});
// POST /api/admin/users { name, email, password, is_admin? } — admin creates accounts.
// Pending workspace invites for that email are linked automatically.
router.post('/users', async (req, res) => {
const { name = '', email = '', password = '', is_admin = false } = req.body ?? {};
if (String(name).trim().length < 1 || String(name).length > 100) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' });
}
if (String(password).length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 chars' });
}
const { default: bcrypt } = await import('bcryptjs');
const hash = await bcrypt.hash(String(password), 12);
try {
const { rows } = await query(
`INSERT INTO users (email, name, password_hash, is_admin)
VALUES ($1,$2,$3,$4) RETURNING id, name, email, is_admin, created_at`,
[String(email).toLowerCase(), String(name).trim(), hash, Boolean(is_admin)],
);
const user = rows[0];
await query('UPDATE workspace_invites SET user_id = $1 WHERE email = $2 AND user_id IS NULL', [
user.id, user.email,
]);
return res.status(201).json({ user });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Email already registered' });
throw err;
}
});
// PATCH /api/admin/users/:id { is_admin }
router.patch('/users/:id', async (req, res) => {
const { is_admin } = req.body ?? {};
+10 -3
View File
@@ -17,12 +17,19 @@ function publicUser(row) {
theme: row.theme || 'system',
bgImage: row.bg_image || '',
bgBlur: row.bg_blur !== false,
bgGlass: row.bg_glass !== false,
createdAt: row.created_at,
};
}
// POST /api/auth/register { email, name, password }
// POST /api/auth/register — DISABLED after installation.
// The web installer creates the first admin; afterwards only admins
// may create users (POST /api/admin/users).
router.post('/register', async (req, res) => {
const { rows } = await query("SELECT value FROM app_settings WHERE key = 'installed'");
if (rows[0]?.value === 'true') {
return res.status(403).json({ error: 'Public registration is disabled. Ask an admin to create your account.' });
}
const { email = '', name = '', password = '' } = req.body ?? {};
if (!EMAIL_RE.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' });
@@ -38,7 +45,7 @@ router.post('/register', async (req, res) => {
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
const hash = await bcrypt.hash(String(password), 12);
const { rows } = await query(
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at',
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at',
[String(email).toLowerCase(), String(name).trim(), hash],
);
const token = signToken(rows[0]);
@@ -68,7 +75,7 @@ router.post('/login', async (req, res) => {
// GET /api/auth/me
router.get('/me', requireAuth, async (req, res) => {
const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at FROM users WHERE id = $1', [req.user.id]);
const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
return res.json({ user: publicUser(rows[0]) });
});
+72
View File
@@ -0,0 +1,72 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/chat/:workspaceId — last 100 messages
router.get('/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
`SELECT m.*, u.name AS author_name, u.avatar AS author_avatar
FROM chat_messages m LEFT JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
[req.params.workspaceId],
);
return res.json({ messages: rows.reverse() });
});
// POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others
router.post('/:workspaceId', async (req, res) => {
const wsId = req.params.workspaceId;
const role = await getRole(wsId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { body = '' } = req.body ?? {};
if (String(body).trim().length < 1 || String(body).length > 2000) {
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
}
const { rows } = await query(
'INSERT INTO chat_messages (workspace_id, user_id, body) VALUES ($1,$2,$3) RETURNING *',
[wsId, req.user.id, String(body).trim()],
);
const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]);
const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar };
const io = req.app.get('io');
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
// Notify all other members
const { rows: members } = await query(
'SELECT user_id FROM workspace_members WHERE workspace_id = $1 AND user_id <> $2',
[wsId, req.user.id],
);
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
for (const m of members) {
await query(
`INSERT INTO notifications (user_id, type, title, body, link)
VALUES ($1,'chat',$2,$3,$4)`,
[m.user_id, `💬 ${author[0]?.name || 'Someone'} in ${wsRows[0]?.name || ''}`,
String(body).trim().slice(0, 140), '/chat'],
);
io?.to(`user:${m.user_id}`).emit('notification:new', { type: 'chat' });
}
return res.status(201).json({ message: msg });
});
// DELETE /api/chat/message/:id (own or admin+)
router.delete('/message/:id', async (req, res) => {
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
const msg = rows[0];
if (!msg) return res.status(404).json({ error: 'Not found' });
const role = await getRole(msg.workspace_id, req.user.id);
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (msg.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await query('DELETE FROM chat_messages WHERE id = $1', [msg.id]);
req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:deleted', { id: msg.id });
return res.status(204).end();
});
export default router;
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, ensureDefaultGroups, PERMS } from './guards.js';
const router = Router();
router.use(requireAuth);
async function isWsAdmin(workspaceId, userId) {
const role = await getRole(workspaceId, userId);
return role === 'owner' || role === 'admin';
}
// GET /api/groups/by-workspace/:workspaceId (seeds defaults on first call)
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
await ensureDefaultGroups(req.params.workspaceId);
const { rows } = await query(
`SELECT g.*, (SELECT COUNT(*)::int FROM group_members m WHERE m.group_id = g.id) AS members
FROM groups g WHERE g.workspace_id = $1 ORDER BY g.created_at`,
[req.params.workspaceId],
);
return res.json({ groups: rows, availablePermissions: PERMS });
});
// POST /api/groups { workspace_id, name, permissions[] } (workspace admin+)
router.post('/', async (req, res) => {
const { workspace_id, name = '', permissions = [] } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await isWsAdmin(workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const perms = [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))];
try {
const { rows } = await query(
'INSERT INTO groups (workspace_id, name, permissions) VALUES ($1,$2,$3) RETURNING *',
[workspace_id, String(name).trim(), perms],
);
return res.status(201).json({ group: rows[0] });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Group name taken' });
throw err;
}
});
// PATCH /api/groups/:id { name?, permissions[] } (workspace admin+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { name, permissions } = req.body ?? {};
const perms = permissions !== undefined
? [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))]
: null;
const { rows } = await query(
`UPDATE groups SET name = COALESCE($1, name),
permissions = COALESCE($2, permissions) WHERE id = $3 RETURNING *`,
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
perms,
group.id,
],
);
return res.json({ group: rows[0] });
});
// DELETE /api/groups/:id (workspace admin+)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM groups WHERE id = $1', [group.id]);
return res.status(204).end();
});
// GET /api/groups/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar FROM group_members m
JOIN users u ON u.id = m.user_id WHERE m.group_id = $1`,
[group.id],
);
return res.json({ members: rows });
});
// POST /api/groups/:id/members { user_id } (workspace admin+)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { user_id } = req.body ?? {};
if (!(await getRole(group.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
'INSERT INTO group_members (group_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[group.id, user_id],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/groups/:id/members/:userId (workspace admin+)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM group_members WHERE group_id = $1 AND user_id = $2', [group.id, req.params.userId]);
return res.status(204).end();
});
export default router;
+38 -2
View File
@@ -13,8 +13,7 @@ export async function getRole(workspaceId, userId) {
}
/** Require membership; optionally require minimum role. Attaches req.membership. */
export function requireWorkspace(minRole = 'viewer') {
return async (req, res, next) => {
export function requireWorkspace(minRole = 'viewer') { return async (req, res, next) => {
const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id;
if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
const role = await getRole(workspaceId, req.user.id);
@@ -39,3 +38,40 @@ export async function requireAdmin(req, res, next) {
if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
return next();
}
export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
/** True if user holds a workspace permission via groups (owner/admin always pass). */
export async function hasPerm(workspaceId, userId, perm) {
const role = await getRole(workspaceId, userId);
if (role === 'owner' || role === 'admin') return true;
if (!role) return false;
const { rows } = await query(
`SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id
WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`,
[workspaceId, userId, perm],
);
return !!rows[0];
}
/** Seed default permission groups for a workspace (idempotent). */
export async function ensureDefaultGroups(workspaceId) {
await query(
`INSERT INTO groups (workspace_id, name, permissions)
VALUES ($1, 'Team Creators', $2)
ON CONFLICT (workspace_id, name) DO NOTHING`,
[workspaceId, ['teams.create', 'teams.edit', 'teams.invite']],
);
}
/** True if user manages the team (manager, workspace owner/admin, or teams.edit perm). */
export async function canManageTeam(team, userId) {
const role = await getRole(team.workspace_id, userId);
if (role === 'owner' || role === 'admin') return true;
const { rows } = await query(
'SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND role = $3',
[team.id, userId, 'manager'],
);
if (rows[0]) return true;
return hasPerm(team.workspace_id, userId, 'teams.edit');
}
+54
View File
@@ -0,0 +1,54 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/invites/mine — my pending workspace invites
router.get('/mine', async (req, res) => {
const { rows } = await query(
`SELECT i.*, w.name AS workspace_name, u.name AS inviter_name
FROM workspace_invites i
JOIN workspaces w ON w.id = i.workspace_id
LEFT JOIN users u ON u.id = i.invited_by
WHERE (i.user_id = $1 OR i.email = $2) AND i.status = 'pending'
ORDER BY i.created_at DESC`,
[req.user.id, req.user.email],
);
return res.json({ invites: rows });
});
// POST /api/invites/:id/accept
router.post('/:id/accept', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.status !== 'pending') return res.status(409).json({ error: `Invite ${inv.status}` });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[inv.workspace_id, req.user.id, inv.role],
);
await query(`UPDATE workspace_invites SET status = 'accepted', user_id = $1 WHERE id = $2`, [
req.user.id, inv.id,
]);
return res.json({ ok: true, workspace_id: inv.workspace_id });
});
// POST /api/invites/:id/decline
router.post('/:id/decline', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
await query(`UPDATE workspace_invites SET status = 'declined' WHERE id = $1`, [inv.id]);
return res.json({ ok: true });
});
export default router;
+38
View File
@@ -0,0 +1,38 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/notifications/mine?unread=1
router.get('/mine', async (req, res) => {
const params = [req.user.id];
let cond = 'user_id = $1';
if (req.query.unread === '1') cond += ' AND is_read = FALSE';
const { rows } = await query(
`SELECT * FROM notifications WHERE ${cond} ORDER BY created_at DESC LIMIT 50`,
params,
);
const { rows: counts } = await query(
'SELECT COUNT(*)::int AS c FROM notifications WHERE user_id = $1 AND is_read = FALSE',
[req.user.id],
);
return res.json({ notifications: rows, unread: counts[0]?.c ?? 0 });
});
// PATCH /api/notifications/:id/read
router.patch('/:id/read', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2', [
req.params.id, req.user.id,
]);
return res.json({ ok: true });
});
// POST /api/notifications/read-all
router.post('/read-all', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE user_id = $1', [req.user.id]);
return res.json({ ok: true });
});
export default router;
+37 -13
View File
@@ -13,7 +13,7 @@ async function canWrite(userId, workspaceId) {
return role === 'owner' || role === 'admin' || role === 'member';
}
async function checkLinks(workspaceId, book_id, page_id) {
async function checkLinks(workspaceId, book_id, page_id, team_id, assignee_id) {
if (book_id) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspaceId]);
if (!rows[0]) return 'Invalid book_id for this workspace';
@@ -22,15 +22,25 @@ async function checkLinks(workspaceId, book_id, page_id) {
const { rows } = await query('SELECT id FROM pages WHERE id = $1 AND workspace_id = $2', [page_id, workspaceId]);
if (!rows[0]) return 'Invalid page_id for this workspace';
}
if (team_id) {
const { rows } = await query('SELECT id FROM teams WHERE id = $1 AND workspace_id = $2', [team_id, workspaceId]);
if (!rows[0]) return 'Invalid team_id for this workspace';
}
if (assignee_id) {
const { rows } = await query(
'SELECT 1 FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
[workspaceId, assignee_id],
);
if (!rows[0]) return 'Assignee must be a workspace member';
}
return null;
}
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=
// from/to filter due_at (used by the calendar).
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=&team_id=&assignee=
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { status, from, to } = req.query;
const { status, from, to, team_id, assignee } = req.query;
const conds = ['t.workspace_id = $1'];
const params = [req.params.workspaceId];
if (status && STATUSES.includes(String(status))) {
@@ -45,18 +55,28 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
params.push(new Date(String(to)));
conds.push(`t.due_at <= $${params.length}`);
}
if (team_id) {
params.push(String(team_id));
conds.push(`t.team_id = $${params.length}`);
}
if (assignee) {
params.push(String(assignee));
conds.push(`t.assignee_id = $${params.length}`);
}
const { rows } = await query(
`SELECT t.*, b.title AS book_title, p.title AS page_title
`SELECT t.*, b.title AS book_title, p.title AS page_title,
tm.name AS team_name, u.name AS assignee_name, u.avatar AS assignee_avatar
FROM tasks t LEFT JOIN books b ON b.id = t.book_id LEFT JOIN pages p ON p.id = t.page_id
LEFT JOIN teams tm ON tm.id = t.team_id LEFT JOIN users u ON u.id = t.assignee_id
WHERE ${conds.join(' AND ')} ORDER BY t.due_at NULLS LAST, t.position, t.created_at`,
params,
);
return res.json({ tasks: rows });
});
// POST /api/tasks { workspace_id, book_id?, page_id?, title, status?, priority?, due_at? }
// POST /api/tasks { workspace_id, book_id?, page_id?, team_id?, assignee_id?, title, ... }
router.post('/', async (req, res) => {
const { workspace_id, book_id = null, page_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {};
const { workspace_id, book_id = null, page_id = null, team_id = null, assignee_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await canWrite(req.user.id, workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot create tasks' });
@@ -66,12 +86,12 @@ router.post('/', async (req, res) => {
}
if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
const linkErr = await checkLinks(workspace_id, book_id, page_id);
const linkErr = await checkLinks(workspace_id, book_id, page_id, team_id, assignee_id);
if (linkErr) return res.status(400).json({ error: linkErr });
const { rows } = await query(
`INSERT INTO tasks (workspace_id, book_id, page_id, title, status, priority, due_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[workspace_id, book_id, page_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id],
`INSERT INTO tasks (workspace_id, book_id, page_id, team_id, assignee_id, title, status, priority, due_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[workspace_id, book_id, page_id, team_id, assignee_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id],
);
return res.status(201).json({ task: rows[0] });
});
@@ -84,7 +104,7 @@ router.patch('/:id', async (req, res) => {
if (!(await canWrite(req.user.id, task.workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot edit' });
}
const { title, status, priority, due_at, book_id, page_id, position } = req.body ?? {};
const { title, status, priority, due_at, book_id, page_id, team_id, assignee_id, position } = req.body ?? {};
if (status !== undefined && !STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (priority !== undefined && !PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
if (title !== undefined && (String(title).trim().length < 1 || String(title).length > 300)) {
@@ -92,7 +112,9 @@ router.patch('/:id', async (req, res) => {
}
const nextBook = book_id !== undefined ? book_id : task.book_id;
const nextPage = page_id !== undefined ? page_id : task.page_id;
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage);
const nextTeam = team_id !== undefined ? team_id : task.team_id;
const nextAssignee = assignee_id !== undefined ? assignee_id : task.assignee_id;
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage, nextTeam, nextAssignee);
if (linkErr) return res.status(400).json({ error: linkErr });
const sets = [];
const params = [];
@@ -103,6 +125,8 @@ router.patch('/:id', async (req, res) => {
if (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null);
if (book_id !== undefined) push('book_id', book_id);
if (page_id !== undefined) push('page_id', page_id);
if (team_id !== undefined) push('team_id', team_id);
if (assignee_id !== undefined) push('assignee_id', assignee_id);
if (position !== undefined) push('position', Number(position));
if (sets.length === 0) return res.json({ task });
params.push(task.id);
+153
View File
@@ -0,0 +1,153 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, hasPerm, canManageTeam, ensureDefaultGroups } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/teams/by-workspace/:workspaceId (with member counts)
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
`SELECT t.*, (SELECT COUNT(*)::int FROM team_members m WHERE m.team_id = t.id) AS members,
EXISTS(SELECT 1 FROM team_members m WHERE m.team_id = t.id AND m.user_id = $2) AS joined
FROM teams t WHERE t.workspace_id = $1 ORDER BY t.created_at`,
[req.params.workspaceId, req.user.id],
);
return res.json({ teams: rows });
});
// POST /api/teams { workspace_id, name, icon? } (teams.create perm; creator becomes manager)
router.post('/', async (req, res) => {
const { workspace_id, name = '', icon = '👥' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await hasPerm(workspace_id, req.user.id, 'teams.create'))) {
return res.status(403).json({ error: 'No permission to create teams' });
}
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'INSERT INTO teams (workspace_id, name, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
[workspace_id, String(name).trim(), String(icon).slice(0, 20) || '👥', req.user.id],
);
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,'manager')
ON CONFLICT DO NOTHING`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ team: { ...rows[0], members: 1 } });
});
// PATCH /api/teams/:id { name?, icon? } (manager or teams.edit)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
const { name, icon } = req.body ?? {};
const { rows } = await query(
'UPDATE teams SET name = COALESCE($1, name), icon = COALESCE($2, icon) WHERE id = $3 RETURNING *',
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
icon !== undefined ? String(icon).slice(0, 20) : null,
team.id,
],
);
return res.json({ team: rows[0] });
});
// DELETE /api/teams/:id (manager or teams.edit; linked workspaces keep running, badge cleared)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM teams WHERE id = $1', [team.id]);
return res.status(204).end();
});
// GET /api/teams/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(team.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar, m.role FROM team_members m
JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at`,
[team.id],
);
return res.json({ members: rows });
});
// POST /api/teams/:id/members { user_id, role? } (manager or teams.invite; user must be workspace member)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'No permission to invite to teams' });
const { user_id, role = 'member' } = req.body ?? {};
if (!['manager', 'member'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (!(await getRole(team.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[team.id, user_id, role],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/teams/:id/members/:userId (manager or teams.invite)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM team_members WHERE team_id = $1 AND user_id = $2', [team.id, req.params.userId]);
return res.status(204).end();
});
// POST /api/teams/:id/workspace { name } — team creates its own workspace.
// The UI shows a TEAM_"name" badge behind the workspace name.
router.post('/:id/workspace', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
const { name = '' } = req.body ?? {};
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'INSERT INTO workspaces (name, owner_id, team_id) VALUES ($1,$2,$3) RETURNING *',
[String(name).trim(), req.user.id, team.id],
);
await ensureDefaultGroups(rows[0].id);
// Add all team members to the new workspace (as members)
const { rows: tm } = await query('SELECT user_id FROM team_members WHERE team_id = $1', [team.id]);
for (const m of tm) {
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,'member')
ON CONFLICT DO NOTHING`,
[rows[0].id, m.user_id],
);
}
await query(
`UPDATE workspace_members SET role = 'owner' WHERE workspace_id = $1 AND user_id = $2`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ workspace: { ...rows[0], team_name: team.name } });
});
export default router;
+39
View File
@@ -0,0 +1,39 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
const uploadDir = path.join(process.cwd(), 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || '').toLowerCase().slice(0, 8);
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'));
},
});
// POST /api/upload — avatar / background image upload (returns relative URL)
router.post('/', requireAuth, (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || 'Upload failed' });
if (!req.file) return res.status(400).json({ error: 'No file' });
return res.status(201).json({ url: `/uploads/${req.file.filename}` });
});
});
export default router;
+8 -5
View File
@@ -36,7 +36,7 @@ router.get('/discover', async (req, res) => {
// PATCH /api/users/me — profile + appearance settings
router.patch('/me', async (req, res) => {
const { name, email, avatar, locale, theme, bg_image, bg_blur } = req.body ?? {};
const { name, email, avatar, locale, theme, bg_image, bg_blur, bg_glass } = req.body ?? {};
if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
@@ -64,9 +64,10 @@ router.patch('/me', async (req, res) => {
locale = COALESCE($4, locale),
theme = COALESCE($5, theme),
bg_image = COALESCE($6, bg_image),
bg_blur = COALESCE($7, bg_blur)
WHERE id = $8
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at`,
bg_blur = COALESCE($7, bg_blur),
bg_glass = COALESCE($8, bg_glass)
WHERE id = $9
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at`,
[
name !== undefined ? String(name).trim() : null,
email !== undefined ? String(email).toLowerCase() : null,
@@ -75,6 +76,7 @@ router.patch('/me', async (req, res) => {
theme !== undefined ? theme : null,
bg_image !== undefined ? String(bg_image).slice(0, 2000) : null,
bg_blur !== undefined ? Boolean(bg_blur) : null,
bg_glass !== undefined ? Boolean(bg_glass) : null,
req.user.id,
],
);
@@ -83,7 +85,8 @@ router.patch('/me', async (req, res) => {
user: {
id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin,
avatar: u.avatar || '', locale: u.locale, theme: u.theme,
bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false, createdAt: u.created_at,
bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false,
bgGlass: u.bg_glass !== false, createdAt: u.created_at,
},
});
} catch (err) {
+43 -12
View File
@@ -1,16 +1,18 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, requireWorkspace, ROLES } from './guards.js';
import { getRole, requireWorkspace, hasPerm, ensureDefaultGroups, ROLES } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/workspaces — list my workspaces
// GET /api/workspaces — list my workspaces (with team badge info)
router.get('/', async (req, res) => {
const { rows } = await query(
`SELECT w.id, w.name, w.owner_id, w.created_at, w.updated_at, m.role
`SELECT w.id, w.name, w.owner_id, w.team_id, w.created_at, w.updated_at, m.role,
t.name AS team_name
FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id
LEFT JOIN teams t ON t.id = w.team_id
WHERE m.user_id = $1 ORDER BY w.updated_at DESC`,
[req.user.id],
);
@@ -27,6 +29,7 @@ router.post('/', async (req, res) => {
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
[String(name).trim(), req.user.id],
);
await ensureDefaultGroups(rows[0].id);
return res.status(201).json({ workspace: rows[0] });
});
@@ -67,20 +70,48 @@ router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
return res.json({ members: rows });
});
// POST /api/workspaces/:id/members { email, role } (admin+; cannot grant owner)
router.post('/:id/members', requireWorkspace('admin'), async (req, res) => {
// POST /api/workspaces/:id/members { email, role } — creates a pending invite
// + notification (admin+ or workspace.invite permission; cannot grant owner)
router.post('/:id/members', requireWorkspace('viewer'), async (req, res) => {
const wsId = req.params.id;
const myRole = req.membership.role;
const allowed = myRole === 'owner' || myRole === 'admin' || (await hasPerm(wsId, req.user.id, 'workspace.invite'));
if (!allowed) return res.status(403).json({ error: 'No invite permission' });
const { email = '', role = 'member' } = req.body ?? {};
if (!ROLES.includes(role) || role === 'owner') {
return res.status(400).json({ error: 'Role must be admin, member or viewer' });
}
const { rows: users } = await query('SELECT id FROM users WHERE email = $1', [String(email).toLowerCase()]);
if (!users[0]) return res.status(404).json({ error: 'No user with that email (they must register first)' });
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[req.params.id, users[0].id, role],
const targetEmail = String(email).toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(targetEmail)) {
return res.status(400).json({ error: 'Valid email required' });
}
const { rows: users } = await query('SELECT id, name FROM users WHERE email = $1', [targetEmail]);
const target = users[0] || null;
if (target) {
const existing = await getRole(wsId, target.id);
if (existing) return res.status(409).json({ error: 'User is already a member' });
const dup = await query(
`SELECT id FROM workspace_invites WHERE workspace_id = $1 AND user_id = $2 AND status = 'pending'`,
[wsId, target.id],
);
if (dup.rows[0]) return res.status(409).json({ error: 'Invite already pending' });
}
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
const { rows: inv } = await query(
`INSERT INTO workspace_invites (workspace_id, email, user_id, role, invited_by)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[wsId, targetEmail, target?.id || null, role, req.user.id],
);
return res.status(201).json({ ok: true });
if (target) {
await query(
`INSERT INTO notifications (user_id, type, title, body, link)
VALUES ($1,'invite',$2,$3,$4)`,
[target.id, `Workspace invite: ${wsRows[0]?.name || ''}`,
`You were invited as ${role}. Accept it from the bell menu.`, `/api/invites/${inv[0].id}`],
);
req.app.get('io')?.to(`user:${target.id}`).emit('notification:new', { type: 'invite' });
}
return res.status(201).json({ invite: inv[0] });
});
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
+16
View File
@@ -38,6 +38,22 @@ export function initSocket(httpServer, corsOrigin) {
io.on('connection', (socket) => {
const user = socket.data.user;
// Personal room for notifications (bell).
socket.join(`user:${user.id}`);
socket.on('chat:join', async ({ workspaceId }) => {
try {
if (!workspaceId) return;
const { rows } = await query(
'SELECT 1 FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
[workspaceId, user.id],
);
if (!rows[0]) return socket.emit('error:message', { error: 'Forbidden' });
socket.join(`chat:${workspaceId}`);
} catch {
socket.emit('error:message', { error: 'Join failed' });
}
});
socket.on('page:join', async ({ pageId }) => {
try {
+19 -1
View File
@@ -84,7 +84,25 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML).
## Admin (requires `is_admin`)
- `GET /admin/overview` → counts + recent users
- `GET /admin/users`, `PATCH /admin/users/:id {is_admin}`, `DELETE /admin/users/:id`
- `GET /admin/users`, `POST /admin/users {name,email,password,is_admin?}` (admins create
accounts; public registration is disabled once installed), `PATCH /admin/users/:id`,
`DELETE /admin/users/:id`
## Invites & notifications
- `POST /workspaces/:id/members {email,role}` → pending invite + notification
(workspace admin+ or `workspace.invite` permission)
- `GET /invites/mine`, `POST /invites/:id/accept`, `POST /invites/:id/decline`
- `GET /notifications/mine?unread=1` → `{ notifications, unread }`
- `PATCH /notifications/:id/read`, `POST /notifications/read-all`
## Chat / teams / groups / upload
- `GET /chat/:workspaceId`, `POST /chat/:workspaceId {body}`, `DELETE /chat/message/:id`
- `GET /teams/by-workspace/:id`, `POST /teams`, `PATCH/DELETE /teams/:id`,
team members CRUD, `POST /teams/:id/workspace {name}` (team workspace + badge)
- `GET /groups/by-workspace/:id` (seeds `Team Creators`), groups CRUD + members
- `POST /upload` (multipart image ≤5MB) → `{ url: /uploads/... }`
## Health
+15 -6
View File
@@ -4,21 +4,29 @@ import { useAuthStore } from './stores/auth';
import { useThemeStore } from './stores/theme';
import { useLocaleStore } from './stores/locale';
import { useUiStore } from './stores/ui';
import { fileUrl } from './files';
import SettingsWindow from './components/SettingsWindow.vue';
import TimeTrackerButton from './components/TimeTrackerButton.vue';
import Bell from './components/Bell.vue';
const auth = useAuthStore();
const theme = useThemeStore();
const locale = useLocaleStore();
const ui = useUiStore();
const bgRaw = computed(() => auth.user?.bgImage || '');
const bgIsImage = computed(() => /^(https?:\/\/|\/uploads|data:)/.test(bgRaw.value));
const bgStyle = computed(() => {
const bg = auth.user?.bgImage || '';
if (!bg) return {};
const style = { backgroundImage: bg.startsWith('http') ? `url("${bg}")` : bg };
if (auth.user?.bgBlur !== false) style.filter = 'blur(14px) brightness(0.85)';
if (!bgRaw.value) return {};
const style = {
backgroundImage: bgIsImage.value ? `url("${fileUrl(bgRaw.value)}")` : bgRaw.value,
backgroundSize: 'cover',
backgroundPosition: 'center',
};
if (auth.user?.bgBlur !== false) style.filter = 'blur(16px) brightness(0.9)';
return style;
});
const glassOn = computed(() => !!bgRaw.value && auth.user?.bgGlass !== false);
onMounted(() => {
locale.set(locale.locale);
@@ -28,9 +36,10 @@ onMounted(() => {
</script>
<template>
<div>
<div v-if="bgStyle.backgroundImage" class="bg-layer" :style="bgStyle"></div>
<div :class="{ 'has-bg': !!bgRaw, glass: glassOn }">
<div v-if="bgRaw" class="bg-layer" :style="bgStyle"></div>
<div v-if="auth.isLoggedIn" class="top-right">
<Bell />
<TimeTrackerButton />
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
</div>
+81
View File
@@ -239,9 +239,90 @@ input:focus, select:focus { outline: 2px solid var(--accent); border-color: tran
.toolbar button { padding: 5px 10px; font-size: 13px; }
.comment { border-top: 1px solid var(--border); padding: 8px 0; }
/* ---- Glassmorphism over background image ---- */
.has-bg .layout { background: transparent; }
.glass .sidebar,
.glass .card,
.glass .greeting,
.glass .clock,
.glass .kanban-col,
.glass .cal-cell {
background: color-mix(in srgb, var(--card) 72%, transparent);
backdrop-filter: blur(18px) saturate(1.25);
-webkit-backdrop-filter: blur(18px) saturate(1.25);
}
/* ---- Bell 🔔 ---- */
.bell { position: relative; }
.bell-btn { position: relative; }
.badge {
position: absolute; top: -6px; right: -6px;
background: #ef4444; color: #fff; font-size: 11px; font-weight: 800;
border-radius: 12px; padding: 1px 6px; min-width: 18px; text-align: center;
}
.bell-pop {
position: absolute; right: 0; top: 44px; width: 330px; z-index: 70;
max-height: 60vh; overflow-y: auto; display: grid; gap: 8px;
}
.invite-card { padding: 10px; }
.notif { padding: 8px; border-radius: 8px; cursor: pointer; border-top: 1px solid var(--border); }
.notif.unread { background: var(--accent-soft); }
.notif:hover { background: var(--bg-soft); }
/* ---- Chat 💬 ---- */
.chat-main { display: flex; flex-direction: column; }
.chat-box {
flex: 1; min-height: 50vh; max-height: calc(100vh - 220px);
overflow-y: auto; display: flex; flex-direction: column; gap: 10px;
}
.chat-msg { display: flex; gap: 8px; align-items: flex-start; }
.chat-bubble {
background: var(--bg-soft); border: 1px solid var(--border);
border-radius: 4px 14px 14px 14px; padding: 8px 12px; max-width: 75%;
overflow-wrap: anywhere;
}
.chat-msg.mine { flex-direction: row-reverse; }
.chat-msg.mine .chat-bubble { border-radius: 14px 4px 14px 14px; background: var(--accent-soft); }
.chat-meta { margin-bottom: 2px; }
/* ---- Admin / teams / groups ---- */
.admin-tabs { margin-bottom: 14px; }
.team-tabs { margin-bottom: 12px; }
.team-pill { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; border: none; }
.perm-check { font-size: 13px; }
.perm-check code {
background: var(--bg-soft); border: 1px solid var(--border);
border-radius: 6px; padding: 1px 6px; font-size: 12px;
}
.side-error {
background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca;
border-radius: 10px; padding: 8px 10px; font-size: 13px; margin: 0;
}
[data-theme='dark'] .side-error { background: #3f1d1d; color: #fca5a5; border-color: #7f2d2d; }
.ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- Scaling / overflow fixes ---- */
.main { min-width: 0; }
.row > input, .row > select { min-width: 0; flex: 1; }
.row > button { flex-shrink: 0; }
.sidebar select, .sidebar input { min-width: 0; }
.table-scroll { overflow-x: auto; }
.data-table { min-width: 560px; }
img { max-width: 100%; }
.topbar { flex-wrap: wrap; }
input[type='file'] { padding: 4px; }
@media (max-width: 900px) {
.kanban { grid-template-columns: 1fr; }
.sidebar { width: 240px; min-width: 240px; }
.settings-body { flex-direction: column; }
.settings-tabs { flex-direction: row; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; }
}
@media (max-width: 640px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; min-width: 0; max-height: none; position: static; }
.main { padding: 16px; }
.top-right { top: 8px; right: 8px; }
.clock { font-size: 20px; }
.greeting { flex-direction: column; align-items: flex-start; }
}
+9 -2
View File
@@ -1,13 +1,20 @@
<script setup>
import { computed } from 'vue';
import { fileUrl } from '../files';
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
const isUrl = computed(() => (props.avatar || '').startsWith('http'));
const src = computed(() => {
const a = props.avatar || '';
if (a.startsWith('http') || a.startsWith('/uploads') || a.startsWith('data:')) {
return fileUrl(a);
}
return '';
});
const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase());
</script>
<template>
<span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name">
<img v-if="isUrl" :src="avatar" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<img v-if="src" :src="src" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<span v-else-if="avatar">{{ avatar }}</span>
<span v-else>{{ letter }}</span>
</span>
+107
View File
@@ -0,0 +1,107 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { getSocket } from '../socket';
import { useLocaleStore } from '../stores/locale';
import { useWorkspaceStore } from '../stores/workspace';
const L = useLocaleStore();
const ws = useWorkspaceStore();
const router = useRouter();
const open = ref(false);
const invites = ref([]);
const notifs = ref([]);
const unread = ref(0);
let timer = null;
const total = computed(() => invites.value.length + unread.value);
async function load() {
try {
const { data } = await api.get('/invites/mine');
invites.value = data.invites;
const n = await api.get('/notifications/mine');
notifs.value = n.data.notifications;
unread.value = n.data.unread;
} catch { /* offline */ }
}
async function accept(inv) {
await api.post(`/invites/${inv.id}/accept`);
invites.value = invites.value.filter((i) => i.id !== inv.id);
await ws.fetchWorkspaces();
if (ws.currentId) {
await ws.fetchBooks();
await ws.fetchPages();
}
router.push('/');
}
async function decline(inv) {
await api.post(`/invites/${inv.id}/decline`);
invites.value = invites.value.filter((i) => i.id !== inv.id);
}
async function markAll() {
await api.post('/notifications/read-all');
notifs.value.forEach((n) => (n.is_read = true));
unread.value = 0;
}
async function markOne(n) {
await api.patch(`/notifications/${n.id}/read`);
n.is_read = true;
unread.value = Math.max(0, unread.value - 1);
}
function onPush() {
load();
}
onMounted(() => {
load();
timer = setInterval(load, 20000);
const s = getSocket();
s?.on('notification:new', onPush);
});
onUnmounted(() => {
clearInterval(timer);
getSocket()?.off('notification:new', onPush);
});
</script>
<template>
<div class="bell">
<button class="ghost icon-btn bell-btn" @click="open = !open" :title="L.t('bell.title')">
🔔<span v-if="total" class="badge">{{ total }}</span>
</button>
<div v-if="open" class="card bell-pop">
<h4 style="margin: 0 0 8px">🔔 {{ L.t('bell.title') }}</h4>
<div v-if="invites.length">
<div class="side-label">{{ L.t('bell.invites') }}</div>
<div v-for="inv in invites" :key="inv.id" class="card invite-card">
<strong>{{ inv.workspace_name }}</strong>
<div class="muted">{{ L.t('bell.as') }} {{ inv.role }} · {{ inv.inviter_name || '?' }}</div>
<div class="row" style="margin-top: 6px">
<button class="mini" @click="accept(inv)">✓ {{ L.t('bell.accept') }}</button>
<button class="ghost mini" @click="decline(inv)">✕ {{ L.t('bell.decline') }}</button>
</div>
</div>
</div>
<div class="side-label">{{ L.t('bell.notifs') }}</div>
<div v-if="!notifs.length" class="muted">{{ L.t('bell.empty') }}</div>
<div
v-for="n in notifs.slice(0, 12)" :key="n.id"
class="notif" :class="{ unread: !n.is_read }" @click="markOne(n)"
>
<strong>{{ n.title }}</strong>
<div class="muted">{{ n.body }}</div>
</div>
<button v-if="unread" class="ghost mini" style="margin-top: 6px" @click="markAll">
{{ L.t('bell.readAll') }}
</button>
</div>
</div>
</template>
+4 -4
View File
@@ -12,10 +12,10 @@ onUnmounted(() => clearInterval(timer));
const period = computed(() => {
const h = now.value.getHours();
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '🌅' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '🍱' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '☀️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌇' };
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '☀️' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '☕' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '🌤️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌆' };
return { key: 'greet.night', emoji: '🌙' };
});
+1 -1
View File
@@ -25,7 +25,7 @@ async function invite() {
if (!target) return;
try {
await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
msg.value = `🎉 ${target} → ${role.value}`;
msg.value = `✨ ${target} → ${role.value}`;
email.value = '';
picked.value = null;
emit('invited');
+37 -1
View File
@@ -1,6 +1,7 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import api from '../api';
import { uploadImage } from '../files';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import { useThemeStore } from '../stores/theme';
@@ -19,7 +20,9 @@ const msg = ref('');
const err = ref('');
const bgUrl = ref('');
const bgBlur = ref(true);
const bgGlass = ref(true);
const bgPreset = ref('none');
const uploading = ref('');
const GRADIENTS = {
none: '',
@@ -42,6 +45,7 @@ watch(
([, v]) => v && v === auth.user.bgImage,
)?.[0] || (auth.user.bgImage ? 'custom' : 'none');
bgBlur.value = auth.user.bgBlur !== false;
bgGlass.value = auth.user.bgGlass !== false;
msg.value = '';
err.value = '';
}
@@ -106,13 +110,42 @@ async function saveBackground() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', { bg_image: bgValue(), bg_blur: bgBlur.value });
const { data } = await api.patch('/users/me', {
bg_image: bgValue(), bg_blur: bgBlur.value, bg_glass: bgGlass.value,
});
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function uploadAvatar(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading.value = 'avatar';
try {
form.avatar = await uploadImage(file);
} catch {
err.value = 'Upload failed';
} finally {
uploading.value = '';
}
}
async function uploadBg(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading.value = 'bg';
try {
bgUrl.value = await uploadImage(file);
bgPreset.value = 'custom';
} catch {
err.value = 'Upload failed';
} finally {
uploading.value = '';
}
}
</script>
<template>
@@ -146,6 +179,7 @@ async function saveBackground() {
<div class="emoji-row">
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
</div>
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadAvatar" /> {{ uploading === 'avatar' ? '⏳' : L.t('set.upload') }}</label>
<label>{{ L.t('set.name') }}</label>
<input v-model="form.name" />
<label>{{ L.t('set.email') }}</label>
@@ -190,8 +224,10 @@ async function saveBackground() {
</div>
<label>{{ L.t('set.bgUrl') }}</label>
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadBg" /> {{ uploading === 'bg' ? '⏳' : L.t('set.upload') }}</label>
<p class="muted">{{ L.t('set.bgUrlHint') }}</p>
<label class="row"><input type="checkbox" v-model="bgBlur" style="width: auto" /> {{ L.t('set.bgBlur') }}</label>
<label class="row"><input type="checkbox" v-model="bgGlass" style="width: auto" /> {{ L.t('set.bgGlass') }}</label>
<button @click="saveBackground">{{ L.t('set.save') }}</button>
</div>
+74 -32
View File
@@ -21,6 +21,7 @@ const newPage = ref('');
const newBook = ref('');
const search = ref('');
const results = ref([]);
const sideErr = ref('');
const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}'));
function toggleBook(id) {
@@ -28,9 +29,19 @@ function toggleBook(id) {
localStorage.setItem('collapsedBooks', JSON.stringify(collapsed.value));
}
function fail(e, fallback) {
sideErr.value = e.response?.data?.error || fallback || 'Error';
setTimeout(() => (sideErr.value = ''), 4000);
}
async function refresh() {
await ws.fetchBooks();
await ws.fetchPages();
try {
await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
} catch (e) {
fail(e);
}
}
async function switchWs(id) {
@@ -40,39 +51,60 @@ async function switchWs(id) {
async function createWs() {
if (!newWs.value.trim()) return;
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await refresh();
try {
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await refresh();
} catch (e) {
fail(e);
}
}
async function createPage(bookId = null) {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, title,
});
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
async function createPage(bookId = null, parentId = null) {
try {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, parent_id: parentId, title,
});
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
} catch (e) {
fail(e);
}
}
async function createBook() {
if (!newBook.value.trim()) return;
await ws.createBook(newBook.value.trim(), '📚');
newBook.value = '';
try {
await ws.createBook(newBook.value.trim(), '📚');
newBook.value = '';
} catch (e) {
fail(e);
}
}
async function deleteBook(id) {
if (!confirm('🗑️?')) return;
await api.delete(`/books/${id}`);
await refresh();
try {
await api.delete(`/books/${id}`);
await ws.fetchBooks();
await ws.fetchPages();
} catch (e) {
fail(e);
}
}
async function doSearch() {
if (!search.value.trim()) { results.value = []; return; }
const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId },
});
results.value = data.results;
try {
const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId },
});
results.value = data.results;
} catch {
results.value = [];
}
}
function logout() {
@@ -87,13 +119,20 @@ onMounted(refresh);
<aside class="sidebar">
<div class="user-chip">
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
<strong style="flex: 1">{{ auth.user?.name }}</strong>
<strong style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis">{{ auth.user?.name }}</strong>
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
</div>
<select :value="ws.currentId" @change="switchWs($event.target.value)">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
</select>
<p v-if="sideErr" class="side-error">{{ sideErr }}</p>
<div class="row">
<select :value="ws.currentId" @change="switchWs($event.target.value)" style="flex: 1; min-width: 0">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">
{{ w.name }}{{ w.team_name ? ` · TEAM_${w.team_name}` : '' }} ({{ w.role }})
</option>
</select>
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')">⟳</button>
</div>
<div class="row">
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
<button @click="createWs">+</button>
@@ -104,6 +143,7 @@ onMounted(refresh);
<router-link to="/tasks">✅ {{ L.t('nav.tasks') }}</router-link>
<router-link to="/calendar">🗓️ {{ L.t('nav.calendar') }}</router-link>
<router-link to="/time">⏱️ {{ L.t('nav.time') }}</router-link>
<router-link to="/chat">💬 {{ L.t('nav.chat') }}</router-link>
<router-link v-if="auth.user?.isAdmin" to="/admin">🛡️ {{ L.t('nav.admin') }}</router-link>
</nav>
@@ -121,28 +161,30 @@ onMounted(refresh);
</div>
<div v-for="b in ws.books" :key="b.id" class="book">
<div class="tree-item book-head" @click="toggleBook(b.id)">
<span>{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span>
<button class="ghost mini" @click.stop="createPage(b.id)" title="+">+</button>
<span class="ellipsis">{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span class="row">
<button class="ghost mini" @click.stop="createPage(b.id, null)" title="+">+</button>
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="🗑️">🗑️</button>
</span>
</div>
<div v-if="!collapsed[b.id]" class="book-pages">
<PageTree
:nodes="ws.treeFor(b.id)" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage(b.id)"
@open="router.push(`/page/${$event}`)"
@add-child="createPage(b.id, $event)"
/>
</div>
</div>
<div class="side-label">{{ L.t('side.pages') }}</div>
<div class="row">
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage()" />
<button @click="createPage()">+</button>
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage(null, null)" />
<button @click="createPage(null, null)">+</button>
</div>
<PageTree
:nodes="ws.tree()" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage()"
@open="router.push(`/page/${$event}`)"
@add-child="createPage(null, $event)"
/>
<div class="side-footer">
+21
View File
@@ -0,0 +1,21 @@
// Resolve avatar/background paths: absolute URLs pass through,
// relative /uploads/... paths are prefixed with the backend origin.
export function fileUrl(path) {
if (!path) return '';
if (/^https?:\/\//i.test(path) || path.startsWith('data:') || path.startsWith('linear-gradient')) {
return path;
}
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
const origin = apiBase.replace(/\/api\/?$/, '');
return `${origin}${path.startsWith('/') ? path : `/${path}`}`;
}
export async function uploadImage(file) {
const { default: api } = await import('./api');
const form = new FormData();
form.append('file', file);
const { data } = await api.post('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.url; // relative /uploads/... path
}
+32 -4
View File
@@ -1,12 +1,37 @@
export default {
nav: {
dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung',
admin: 'Admin', logout: 'Abmelden',
admin: 'Admin', logout: 'Abmelden', chat: 'Chat',
},
side: {
workspaces: 'Workspaces', newWorkspace: 'Neuer Workspace', searchPh: 'Seiten suchen …',
newPagePh: 'Neue Seite', newBookPh: 'Neues Book', books: '📚 Books', pages: '📄 Seiten',
members: 'Mitglieder', role: 'Rolle',
members: 'Mitglieder', role: 'Rolle', refresh: 'Aktualisieren', teamBadge: 'Team',
},
auth: {
title: 'Willkommen zurück', sub: 'Melde dich an.',
email: 'E-Mail', password: 'Passwort', btn: 'Anmelden',
locked: '🔒 Accounts werden vom Admin erstellt.',
},
bell: {
title: 'Mitteilungen', invites: 'Einladungen', notifs: 'Neuigkeiten',
empty: 'Alles gelesen. ✨', as: 'als', accept: 'Annehmen', decline: 'Ablehnen',
readAll: 'Alle als gelesen markieren',
},
chat: {
title: 'Team-Chat', ph: 'Nachricht schreiben …', send: 'Senden',
empty: 'Noch keine Nachrichten. Sag Hallo! 👋',
},
teams: {
title: 'Teams', new: 'Neues Team', ph: 'Teamname …',
members: 'Mitglieder', makeWs: 'Workspace für Team erstellen',
wsName: 'Workspace-Name …', manager: 'Manager', member: 'Mitglied',
addMember: 'Hinzufügen', pickUser: 'User wählen …',
noTeams: 'Noch keine Teams. Erstelle eins! 🚀',
},
groups: {
title: 'Gruppen & Rechte', new: 'Neue Gruppe', ph: 'Gruppenname …',
members: 'Mitglieder', save: 'Speichern', perms: 'Rechte',
},
greet: {
morning: 'Guten Morgen', midday: 'Mahlzeit', afternoon: 'Guten Tag',
@@ -22,14 +47,16 @@ export default {
save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema',
light: '☀️ Hell', dark: '🌙 Dunkel', system: '🖥️ System',
bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:',
bgBlur: 'Hintergrund weichzeichnen (Blur)', close: 'Schließen',
bgBlur: 'Hintergrund weichzeichnen (Blur)', bgGlass: 'Widgets als Glas darstellen',
upload: 'Bild hochladen …', close: 'Schließen',
presetNone: 'Kein Bild', presetCustom: 'Eigene URL',
},
task: {
title: 'Aufgaben', new: 'Neue Aufgabe', ph: 'Was ist zu tun?',
todo: '📝 Offen', doing: '🚧 In Arbeit', done: '✅ Erledigt',
todo: '📝 Offen', doing: '⏳ In Arbeit', done: '✅ Erledigt',
priority: 'Prio', low: 'Niedrig', normal: 'Normal', high: 'Hoch',
due: 'Fällig', noDue: 'Ohne Termin', book: 'Book', page: 'Seite',
team: 'Team', assignee: 'Zuständig', allTeams: 'Alle Teams',
none: '—', overdue: 'Überfällig!', toTodo: '← Offen', toDoing: 'In Arbeit →',
toDone: 'Erledigt →', reopen: '↩ Öffnen',
},
@@ -47,6 +74,7 @@ export default {
workspaces: 'Workspaces', joined: 'Dabei seit', makeAdmin: 'Zum Admin machen',
unmake: 'Admin entziehen', del: 'Löschen', confirm: 'Wirklich löschen?',
you: '(du)', books: 'Books', pages: 'Seiten', tasks: 'Aufgaben', time: 'Zeiteinträge',
createUser: 'Benutzer anlegen', newPw: 'Passwort', create: 'Anlegen', created: 'Angelegt ✅',
},
c: {
save: 'Speichern', cancel: 'Abbrechen', delete: 'Löschen', create: 'Anlegen',
+32 -4
View File
@@ -1,12 +1,37 @@
export default {
nav: {
dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking',
admin: 'Admin', logout: 'Log out',
admin: 'Admin', logout: 'Log out', chat: 'Chat',
},
side: {
workspaces: 'Workspaces', newWorkspace: 'New workspace', searchPh: 'Search pages …',
newPagePh: 'New page', newBookPh: 'New book', books: '📚 Books', pages: '📄 Pages',
members: 'Members', role: 'Role',
members: 'Members', role: 'Role', refresh: 'Refresh', teamBadge: 'Team',
},
auth: {
title: 'Welcome back', sub: 'Log in to your workspace.',
email: 'Email', password: 'Password', btn: 'Log in',
locked: '🔒 Accounts are created by an admin.',
},
bell: {
title: 'Notifications', invites: 'Invites', notifs: 'Updates',
empty: 'All caught up. ✨', as: 'as', accept: 'Accept', decline: 'Decline',
readAll: 'Mark all as read',
},
chat: {
title: 'Team chat', ph: 'Write a message …', send: 'Send',
empty: 'No messages yet. Say hi! 👋',
},
teams: {
title: 'Teams', new: 'New team', ph: 'Team name …',
members: 'Members', makeWs: 'Create workspace for team',
wsName: 'Workspace name …', manager: 'Manager', member: 'Member',
addMember: 'Add', pickUser: 'Pick user …',
noTeams: 'No teams yet. Create one! 🚀',
},
groups: {
title: 'Groups & permissions', new: 'New group', ph: 'Group name …',
members: 'Members', save: 'Save', perms: 'Permissions',
},
greet: {
morning: 'Good morning', midday: 'Enjoy your lunch', afternoon: 'Good afternoon',
@@ -22,14 +47,16 @@ export default {
save: 'Save', saved: 'Saved ✅', theme: 'Color scheme',
light: '☀️ Light', dark: '🌙 Dark', system: '🖥️ System',
bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:',
bgBlur: 'Blur background', close: 'Close',
bgBlur: 'Blur background', bgGlass: 'Glassmorphism widgets',
upload: 'Upload image …', close: 'Close',
presetNone: 'No image', presetCustom: 'Custom URL',
},
task: {
title: 'Tasks', new: 'New task', ph: 'What needs doing?',
todo: '📝 Todo', doing: '🚧 Doing', done: '✅ Done',
todo: '📝 Todo', doing: '⏳ Doing', done: '✅ Done',
priority: 'Priority', low: 'Low', normal: 'Normal', high: 'High',
due: 'Due', noDue: 'No due date', book: 'Book', page: 'Page',
team: 'Team', assignee: 'Assignee', allTeams: 'All teams',
none: '—', overdue: 'Overdue!', toTodo: '← Todo', toDoing: 'Doing →',
toDone: 'Done →', reopen: '↩ Reopen',
},
@@ -47,6 +74,7 @@ export default {
workspaces: 'Workspaces', joined: 'Joined', makeAdmin: 'Make admin',
unmake: 'Remove admin', del: 'Delete', confirm: 'Really delete?',
you: '(you)', books: 'Books', pages: 'Pages', tasks: 'Tasks', time: 'Time entries',
createUser: 'Create user', newPw: 'Password', create: 'Create', created: 'Created ✅',
},
c: {
save: 'Save', cancel: 'Cancel', delete: 'Delete', create: 'Create',
+32 -4
View File
@@ -1,12 +1,37 @@
export default {
nav: {
dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录',
admin: '管理', logout: '退出登录',
admin: '管理', logout: '退出登录', chat: '聊天',
},
side: {
workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …',
newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面',
members: '成员', role: '角色',
members: '成员', role: '角色', refresh: '刷新', teamBadge: '团队',
},
auth: {
title: '欢迎回来', sub: '登录到你的工作区。',
email: '电子邮件', password: '密码', btn: '登录',
locked: '🔒 账号由管理员创建。',
},
bell: {
title: '通知', invites: '邀请', notifs: '动态',
empty: '全部已读。✨', as: '以', accept: '接受', decline: '拒绝',
readAll: '全部标为已读',
},
chat: {
title: '团队聊天', ph: '写消息 …', send: '发送',
empty: '还没有消息,打个招呼吧!👋',
},
teams: {
title: '团队', new: '新建团队', ph: '团队名称 …',
members: '成员', makeWs: '为团队创建工作区',
wsName: '工作区名称 …', manager: '管理员', member: '成员',
addMember: '添加', pickUser: '选择用户 …',
noTeams: '还没有团队,创建一个吧!🚀',
},
groups: {
title: '分组与权限', new: '新建分组', ph: '分组名称 …',
members: '成员', save: '保存', perms: '权限',
},
greet: {
morning: '早上好', midday: '午餐愉快', afternoon: '下午好',
@@ -22,14 +47,16 @@ export default {
save: '保存', saved: '已保存 ✅', theme: '配色方案',
light: '☀️ 浅色', dark: '🌙 深色', system: '🖥️ 跟随系统',
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
bgBlur: '模糊背景', close: '关闭',
bgBlur: '模糊背景', bgGlass: '玻璃拟态组件',
upload: '上传图片 …', close: '关闭',
presetNone: '无背景', presetCustom: '自定义链接',
},
task: {
title: '任务', new: '新建任务', ph: '要做什么?',
todo: '📝 待办', doing: '🚧 进行中', done: '✅ 已完成',
todo: '📝 待办', doing: '⏳ 进行中', done: '✅ 已完成',
priority: '优先级', low: '低', normal: '中', high: '高',
due: '截止', noDue: '无截止日期', book: '书册', page: '页面',
team: '团队', assignee: '负责人', allTeams: '全部团队',
none: '—', overdue: '已逾期!', toTodo: '← 待办', toDoing: '进行中 →',
toDone: '完成 →', reopen: '↩ 重新打开',
},
@@ -47,6 +74,7 @@ export default {
workspaces: '工作区', joined: '加入时间', makeAdmin: '设为管理员',
unmake: '取消管理员', del: '删除', confirm: '确认删除?',
you: '(你)', books: '书册', pages: '页面', tasks: '任务', time: '时间记录',
createUser: '创建用户', newPw: '密码', create: '创建', created: '已创建 ✅',
},
c: {
save: '保存', cancel: '取消', delete: '删除', create: '创建',
+2 -2
View File
@@ -2,24 +2,24 @@ import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useSetupStore } from '../stores/setup';
import Login from '../views/Login.vue';
import Register from '../views/Register.vue';
import Setup from '../views/Setup.vue';
import Dashboard from '../views/Dashboard.vue';
import PageEditor from '../views/PageEditor.vue';
import Tasks from '../views/Tasks.vue';
import Calendar from '../views/Calendar.vue';
import Time from '../views/Time.vue';
import Chat from '../views/Chat.vue';
import Admin from '../views/Admin.vue';
const routes = [
{ path: '/setup', component: Setup },
{ path: '/login', component: Login, meta: { guest: true } },
{ path: '/register', component: Register, meta: { guest: true } },
{ path: '/', component: Dashboard, meta: { auth: true } },
{ path: '/page/:id', component: PageEditor, meta: { auth: true } },
{ path: '/tasks', component: Tasks, meta: { auth: true } },
{ path: '/calendar', component: Calendar, meta: { auth: true } },
{ path: '/time', component: Time, meta: { auth: true } },
{ path: '/chat', component: Chat, meta: { auth: true } },
{ path: '/admin', component: Admin, meta: { auth: true, admin: true } },
];
+258 -48
View File
@@ -1,36 +1,152 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onMounted, reactive, ref } from 'vue';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
const L = useLocaleStore();
const ws = useWorkspaceStore();
const tab = ref('overview');
const counts = ref({});
const users = ref([]);
const recent = ref([]);
const newUser = reactive({ name: '', email: '', password: '', is_admin: false });
const userMsg = ref('');
async function load() {
const teams = ref([]);
const newTeam = ref('');
const teamMembers = ref({});
const teamWsName = ref({});
const members = ref([]);
const groups = ref([]);
const availPerms = ref([]);
const newGroup = ref('');
const newPerms = ref([]);
const groupMembers = ref({});
async function loadOverview() {
const { data } = await api.get('/admin/overview');
counts.value = data.counts;
recent.value = data.recentUsers;
const u = await api.get('/admin/users');
users.value = u.data.users;
}
async function loadUsers() {
const { data } = await api.get('/admin/users');
users.value = data.users;
}
async function createUser() {
userMsg.value = '';
try {
const { data } = await api.post('/admin/users', { ...newUser });
users.value.push({ ...data.user, workspaces: 0 });
newUser.name = '';
newUser.email = '';
newUser.password = '';
newUser.is_admin = false;
userMsg.value = L.t('admin.created');
} catch (e) {
userMsg.value = e.response?.data?.error || 'Error';
}
}
async function setAdmin(u, val) {
const { data } = await api.patch(`/admin/users/${u.id}`, { is_admin: val });
u.is_admin = data.user.is_admin;
load();
}
async function removeUser(u) {
if (!confirm(`${L.t('admin.confirm')} (${u.email})`)) return;
await api.delete(`/admin/users/${u.id}`);
users.value = users.value.filter((x) => x.id !== u.id);
}
onMounted(load);
async function loadTeams() {
if (!ws.currentId) return;
const { data } = await api.get(`/teams/by-workspace/${ws.currentId}`);
teams.value = data.teams;
const m = await api.get(`/workspaces/${ws.currentId}/members`);
members.value = m.data.members;
}
async function createTeam() {
if (!newTeam.value.trim()) return;
const { data } = await api.post('/teams', { workspace_id: ws.currentId, name: newTeam.value.trim() });
teams.value.push(data.team);
newTeam.value = '';
}
async function removeTeam(t) {
if (!confirm('🗑️?')) return;
await api.delete(`/teams/${t.id}`);
teams.value = teams.value.filter((x) => x.id !== t.id);
}
async function showTeamMembers(t) {
const { data } = await api.get(`/teams/${t.id}/members`);
teamMembers.value[t.id] = data.members;
}
async function addTeamMember(t, userId, role) {
if (!userId) return;
await api.post(`/teams/${t.id}/members`, { user_id: userId, role });
showTeamMembers(t);
}
async function removeTeamMember(t, userId) {
await api.delete(`/teams/${t.id}/members/${userId}`);
showTeamMembers(t);
}
async function makeTeamWs(t) {
const name = teamWsName.value[t.id]?.trim();
if (!name) return;
const { data } = await api.post(`/teams/${t.id}/workspace`, { name });
teamWsName.value[t.id] = '';
await ws.fetchWorkspaces();
alert(`✓ ${data.workspace.name} · TEAM_${t.name}`);
}
async function loadGroups() {
if (!ws.currentId) return;
const { data } = await api.get(`/groups/by-workspace/${ws.currentId}`);
groups.value = data.groups;
availPerms.value = data.availablePermissions;
}
async function createGroup() {
if (!newGroup.value.trim()) return;
const { data } = await api.post('/groups', {
workspace_id: ws.currentId, name: newGroup.value.trim(), permissions: newPerms.value,
});
groups.value.push(data.group);
newGroup.value = '';
newPerms.value = [];
}
async function togglePerm(g, perm) {
const perms = new Set(g.permissions || []);
if (perms.has(perm)) perms.delete(perm);
else perms.add(perm);
const { data } = await api.patch(`/groups/${g.id}`, { permissions: [...perms] });
g.permissions = data.group.permissions;
}
async function removeGroup(g) {
if (!confirm('🗑️?')) return;
await api.delete(`/groups/${g.id}`);
groups.value = groups.value.filter((x) => x.id !== g.id);
}
async function showGroupMembers(g) {
const { data } = await api.get(`/groups/${g.id}/members`);
groupMembers.value[g.id] = data.members;
}
async function addGroupMember(g, userId) {
if (!userId) return;
await api.post(`/groups/${g.id}/members`, { user_id: userId });
showGroupMembers(g);
}
async function removeGroupMember(g, userId) {
await api.delete(`/groups/${g.id}/members/${userId}`);
showGroupMembers(g);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await loadOverview();
await loadUsers();
await loadTeams();
await loadGroups();
});
</script>
<template>
@@ -38,45 +154,139 @@ onMounted(load);
<Sidebar />
<main class="main">
<h2>🛡️ {{ L.t('admin.title') }}</h2>
<h3>{{ L.t('admin.overview') }}</h3>
<div class="stat-grid">
<div class="card stat">👥<strong>{{ counts.users }}</strong>{{ L.t('admin.users') }}</div>
<div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>{{ L.t('nav.dashboard') }}</div>
<div class="card stat">📚<strong>{{ counts.books }}</strong>{{ L.t('admin.books') }}</div>
<div class="card stat">📄<strong>{{ counts.pages }}</strong>{{ L.t('admin.pages') }}</div>
<div class="card stat">✅<strong>{{ counts.tasks }}</strong>{{ L.t('admin.tasks') }}</div>
<div class="card stat">⏱️<strong>{{ counts.timeEntries }}</strong>{{ L.t('admin.time') }}</div>
<div class="row wrap admin-tabs">
<button :class="{ ghost: tab !== 'overview' }" @click="tab = 'overview'">{{ L.t('admin.overview') }}</button>
<button :class="{ ghost: tab !== 'users' }" @click="tab = 'users'">{{ L.t('admin.users') }}</button>
<button :class="{ ghost: tab !== 'teams' }" @click="tab = 'teams'">👥 {{ L.t('teams.title') }}</button>
<button :class="{ ghost: tab !== 'groups' }" @click="tab = 'groups'">🔑 {{ L.t('groups.title') }}</button>
</div>
<h3>{{ L.t('admin.users') }} ({{ users.length }})</h3>
<div class="card">
<table class="data-table">
<thead>
<tr>
<th></th>
<th>{{ L.t('admin.name') }}</th>
<th>{{ L.t('admin.email') }}</th>
<th>{{ L.t('admin.role') }}</th>
<th>{{ L.t('admin.workspaces') }}</th>
<th>{{ L.t('admin.joined') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><Avatar :name="u.name" :size="24" /></td>
<td><strong>{{ u.name }}</strong></td>
<td class="muted">{{ u.email }}</td>
<td>{{ u.is_admin ? `⭐ ${L.t('admin.admin')}` : L.t('admin.user') }}</td>
<td>{{ u.workspaces }}</td>
<td class="muted">{{ new Date(u.created_at).toLocaleDateString() }}</td>
<td class="row">
<button v-if="!u.is_admin" class="ghost mini" @click="setAdmin(u, true)">{{ L.t('admin.makeAdmin') }}</button>
<button v-else class="ghost mini" @click="setAdmin(u, false)">{{ L.t('admin.unmake') }}</button>
<button class="ghost mini" @click="removeUser(u)">🗑️</button>
</td>
</tr>
</tbody>
</table>
<div v-if="tab === 'overview'">
<div class="stat-grid">
<div class="card stat">👥<strong>{{ counts.users }}</strong>{{ L.t('admin.users') }}</div>
<div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>Workspaces</div>
<div class="card stat">📚<strong>{{ counts.books }}</strong>{{ L.t('admin.books') }}</div>
<div class="card stat">📄<strong>{{ counts.pages }}</strong>{{ L.t('admin.pages') }}</div>
<div class="card stat">✅<strong>{{ counts.tasks }}</strong>{{ L.t('admin.tasks') }}</div>
<div class="card stat">⏱️<strong>{{ counts.timeEntries }}</strong>{{ L.t('admin.time') }}</div>
</div>
</div>
<div v-if="tab === 'users'">
<div class="card" style="margin-bottom: 12px">
<h4>+ {{ L.t('admin.createUser') }}</h4>
<div class="row wrap">
<input v-model="newUser.name" :placeholder="L.t('admin.name')" />
<input v-model="newUser.email" type="email" :placeholder="L.t('admin.email')" />
<input v-model="newUser.password" type="password" :placeholder="L.t('admin.newPw')" />
<label class="row"><input type="checkbox" v-model="newUser.is_admin" style="width: auto" /> {{ L.t('admin.admin') }}</label>
<button @click="createUser">{{ L.t('admin.create') }}</button>
</div>
<p v-if="userMsg" class="muted">{{ userMsg }}</p>
</div>
<div class="card table-scroll">
<table class="data-table">
<thead>
<tr>
<th></th><th>{{ L.t('admin.name') }}</th><th>{{ L.t('admin.email') }}</th>
<th>{{ L.t('admin.role') }}</th><th>{{ L.t('admin.workspaces') }}</th>
<th>{{ L.t('admin.joined') }}</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><Avatar :name="u.name" :size="24" /></td>
<td><strong>{{ u.name }}</strong></td>
<td class="muted">{{ u.email }}</td>
<td>{{ u.is_admin ? `⭐ ${L.t('admin.admin')}` : L.t('admin.user') }}</td>
<td>{{ u.workspaces }}</td>
<td class="muted">{{ new Date(u.created_at).toLocaleDateString() }}</td>
<td class="row">
<button v-if="!u.is_admin" class="ghost mini" @click="setAdmin(u, true)">{{ L.t('admin.makeAdmin') }}</button>
<button v-else class="ghost mini" @click="setAdmin(u, false)">{{ L.t('admin.unmake') }}</button>
<button class="ghost mini" @click="removeUser(u)">🗑️</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab === 'teams'">
<div class="card" style="margin-bottom: 12px">
<h4>+ {{ L.t('teams.new') }}</h4>
<div class="row">
<input v-model="newTeam" :placeholder="L.t('teams.ph')" @keyup.enter="createTeam" />
<button @click="createTeam">{{ L.t('c.create') }}</button>
</div>
</div>
<p v-if="!teams.length" class="muted">{{ L.t('teams.noTeams') }}</p>
<div v-for="t in teams" :key="t.id" class="card" style="margin-bottom: 10px">
<div class="row" style="justify-content: space-between">
<strong>{{ t.icon }} {{ t.name }} <span class="muted">({{ t.members }})</span></strong>
<button class="ghost mini" @click="removeTeam(t)">🗑️</button>
</div>
<div class="row wrap" style="margin-top: 8px">
<button class="ghost mini" @click="showTeamMembers(t)">👥 {{ L.t('teams.members') }}</button>
<input v-model="teamWsName[t.id]" :placeholder="L.t('teams.wsName')" style="max-width: 220px" />
<button class="ghost mini" @click="makeTeamWs(t)">🏠 {{ L.t('teams.makeWs') }}</button>
</div>
<div v-if="teamMembers[t.id]?.length" style="margin-top: 8px">
<div v-for="m in teamMembers[t.id]" :key="m.id" class="row member-row">
<Avatar :name="m.name" :avatar="m.avatar" :size="24" />
<span style="flex: 1">{{ m.name }}</span>
<span class="pill">{{ m.role }}</span>
<button class="ghost mini" @click="removeTeamMember(t, m.id)">✕</button>
</div>
</div>
<div class="row" style="margin-top: 8px">
<select @change="addTeamMember(t, $event.target.value, 'member'); $event.target.value = ''" style="max-width: 200px">
<option value="">{{ L.t('teams.pickUser') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
</div>
</div>
<div v-if="tab === 'groups'">
<div class="card" style="margin-bottom: 12px">
<h4>+ {{ L.t('groups.new') }}</h4>
<div class="row wrap">
<input v-model="newGroup" :placeholder="L.t('groups.ph')" @keyup.enter="createGroup" style="flex: 1" />
<button @click="createGroup">{{ L.t('c.create') }}</button>
</div>
<div class="row wrap" style="margin-top: 8px">
<label v-for="p in availPerms" :key="p" class="row perm-check">
<input type="checkbox" :value="p" v-model="newPerms" style="width: auto" /> <code>{{ p }}</code>
</label>
</div>
</div>
<div v-for="g in groups" :key="g.id" class="card" style="margin-bottom: 10px">
<div class="row" style="justify-content: space-between">
<strong>🔑 {{ g.name }} <span class="muted">({{ g.members }})</span></strong>
<button class="ghost mini" @click="removeGroup(g)">🗑️</button>
</div>
<div class="muted">{{ L.t('groups.perms') }}:</div>
<div class="row wrap">
<label v-for="p in availPerms" :key="p" class="row perm-check">
<input type="checkbox" :checked="(g.permissions || []).includes(p)" @change="togglePerm(g, p)" style="width: auto" /> <code>{{ p }}</code>
</label>
</div>
<div class="row" style="margin-top: 8px">
<button class="ghost mini" @click="showGroupMembers(g)">👥 {{ L.t('groups.members') }}</button>
<select @change="addGroupMember(g, $event.target.value); $event.target.value = ''" style="max-width: 200px">
<option value="">{{ L.t('teams.pickUser') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div v-if="groupMembers[g.id]?.length" style="margin-top: 8px">
<div v-for="m in groupMembers[g.id]" :key="m.id" class="row member-row">
<Avatar :name="m.name" :avatar="m.avatar" :size="24" />
<span style="flex: 1">{{ m.name }}</span>
<button class="ghost mini" @click="removeGroupMember(g, m.id)">✕</button>
</div>
</div>
</div>
</div>
</main>
</div>
+96
View File
@@ -0,0 +1,96 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import api from '../api';
import { connectSocket, getSocket } from '../socket';
import { useWorkspaceStore } from '../stores/workspace';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
const ws = useWorkspaceStore();
const auth = useAuthStore();
const L = useLocaleStore();
const messages = ref([]);
const body = ref('');
const box = ref(null);
function scrollDown() {
nextTick(() => {
if (box.value) box.value.scrollTop = box.value.scrollHeight;
});
}
async function load() {
if (!ws.currentId) return;
const { data } = await api.get(`/chat/${ws.currentId}`);
messages.value = data.messages;
const s = connectSocket();
s?.emit('chat:join', { workspaceId: ws.currentId });
scrollDown();
}
function onMsg({ message }) {
if (message.workspace_id !== ws.currentId) return;
messages.value.push(message);
scrollDown();
}
function onDel({ id }) {
messages.value = messages.value.filter((m) => m.id !== id);
}
async function send() {
const text = body.value.trim();
if (!text) return;
body.value = '';
await api.post(`/chat/${ws.currentId}`, { body: text });
}
async function remove(id) {
await api.delete(`/chat/message/${id}`);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await load();
const s = getSocket();
s?.on('chat:message', onMsg);
s?.on('chat:deleted', onDel);
});
onUnmounted(() => {
const s = getSocket();
s?.off('chat:message', onMsg);
s?.off('chat:deleted', onDel);
});
watch(() => ws.currentId, load);
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main chat-main">
<h2>💬 {{ L.t('chat.title') }}</h2>
<div ref="box" class="card chat-box">
<div
v-for="m in messages" :key="m.id"
class="chat-msg" :class="{ mine: m.user_id === auth.user?.id }"
>
<Avatar :name="m.author_name" :avatar="m.author_avatar" :size="30" />
<div class="chat-bubble">
<div class="muted chat-meta">
<strong>{{ m.author_name || '?' }}</strong> ·
{{ new Date(m.created_at).toLocaleString() }}
<span v-if="m.user_id === auth.user?.id" class="link" @click="remove(m.id)"> 🗑</span>
</div>
<div>{{ m.body }}</div>
</div>
</div>
<p v-if="!messages.length" class="muted">{{ L.t('chat.empty') }}</p>
</div>
<div class="row" style="margin-top: 8px">
<input v-model="body" :placeholder="L.t('chat.ph')" @keyup.enter="send" />
<button @click="send">{{ L.t('chat.send') }}</button>
</div>
</main>
</div>
</template>
+2 -1
View File
@@ -21,6 +21,7 @@ async function fetchMembers() {
}
const currentName = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.name || '');
const currentTeam = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.team_name || '');
onMounted(async () => {
await auth.fetchMe().catch(() => {});
@@ -36,7 +37,7 @@ onMounted(async () => {
<Sidebar />
<main class="main">
<GreetingHeader :name="auth.user?.name" />
<p class="muted">🏠 {{ currentName }} · {{ L.t('side.role') || ws.role }}: <strong>{{ ws.role }}</strong></p>
<p class="muted">🏠 {{ currentName }} <span v-if="currentTeam" class="pill team-pill">TEAM_{{ currentTeam }}</span> · {{ L.t('side.role') }}: <strong>{{ ws.role }}</strong></p>
<InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" />
<div class="card" style="margin-top: 12px">
<h4>👥 {{ L.t('side.members') }} ({{ members.length }})</h4>
+9 -6
View File
@@ -2,6 +2,9 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
const L = useLocaleStore();
const email = ref('');
const password = ref('');
@@ -22,14 +25,14 @@ async function submit() {
<template>
<div class="auth-wrap card">
<h1>Welcome back</h1>
<p class="muted">Log in to your workspace.</p>
<h1>{{ L.t('auth.title') }}</h1>
<p class="muted">{{ L.t('auth.sub') }}</p>
<form @submit.prevent="submit" style="display: grid; gap: 10px">
<input v-model="email" type="email" placeholder="Email" required />
<input v-model="password" type="password" placeholder="Password" required />
<button type="submit">Login</button>
<input v-model="email" type="email" :placeholder="L.t('auth.email')" required />
<input v-model="password" type="password" :placeholder="L.t('auth.password')" required />
<button type="submit">{{ L.t('auth.btn') }}</button>
</form>
<p v-if="err" style="color: red">{{ err }}</p>
<p class="muted">No account? <router-link to="/register">Register</router-link></p>
<p class="muted">{{ L.t('auth.locked') }}</p>
</div>
</template>
+1 -1
View File
@@ -158,7 +158,7 @@ onUnmounted(() => {
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• {{ L.t('side.pages') }}</button>
<button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</button>
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">▦ Table</button>
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">⊞ Table</button>
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()">&lt;/&gt;</button>
<button @click="save()">💾 {{ L.t('c.save') }}</button>
</div>
-37
View File
@@ -1,37 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const email = ref('');
const name = ref('');
const password = ref('');
const err = ref('');
const router = useRouter();
const auth = useAuthStore();
async function submit() {
err.value = '';
try {
await auth.register(email.value, name.value, password.value);
router.push('/');
} catch (e) {
err.value = e.response?.data?.error || 'Registration failed';
}
}
</script>
<template>
<div class="auth-wrap card">
<h1>Create account</h1>
<p class="muted">Start your collaborative workspace.</p>
<form @submit.prevent="submit" style="display: grid; gap: 10px">
<input v-model="name" placeholder="Display name" required />
<input v-model="email" type="email" placeholder="Email" required />
<input v-model="password" type="password" placeholder="Password (min 8 chars)" required minlength="8" />
<button type="submit">Register</button>
</form>
<p v-if="err" style="color: red">{{ err }}</p>
<p class="muted">Have an account? <router-link to="/login">Log in</router-link></p>
</div>
</template>
+58 -2
View File
@@ -1,21 +1,27 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const tasks = ref([]);
const teams = ref([]);
const members = ref([]);
const teamFilter = ref('');
const title = ref('');
const due = ref('');
const priority = ref('normal');
const bookId = ref('');
const pageId = ref('');
const teamId = ref('');
const assigneeId = ref('');
const cols = computed(() => ({
todo: tasks.value.filter((t) => t.status === 'todo'),
@@ -32,10 +38,19 @@ function fmtDate(d) {
async function load() {
if (!ws.currentId) return;
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`);
const params = {};
if (teamFilter.value) params.team_id = teamFilter.value;
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params });
tasks.value = data.tasks;
}
async function loadMeta() {
const { data } = await api.get(`/teams/by-workspace/${ws.currentId}`);
teams.value = data.teams;
const m = await api.get(`/workspaces/${ws.currentId}/members`);
members.value = m.data.members;
}
async function create() {
if (!title.value.trim()) return;
const { data } = await api.post('/tasks', {
@@ -45,12 +60,16 @@ async function create() {
due_at: due.value || null,
book_id: bookId.value || null,
page_id: pageId.value || null,
team_id: teamId.value || null,
assignee_id: assigneeId.value || null,
});
tasks.value.unshift(data.task);
title.value = '';
due.value = '';
bookId.value = '';
pageId.value = '';
teamId.value = '';
assigneeId.value = '';
}
async function setStatus(t, status) {
@@ -58,16 +77,27 @@ async function setStatus(t, status) {
Object.assign(t, data.task);
}
async function assign(t, userId) {
const { data } = await api.patch(`/tasks/${t.id}`, { assignee_id: userId || null });
Object.assign(t, data.task);
const m = members.value.find((x) => x.id === data.task.assignee_id);
t.assignee_name = m?.name || null;
t.assignee_avatar = m?.avatar || null;
}
async function remove(t) {
if (!confirm('🗑️?')) return;
await api.delete(`/tasks/${t.id}`);
tasks.value = tasks.value.filter((x) => x.id !== t.id);
}
watch(teamFilter, load);
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
await loadMeta();
await load();
});
</script>
@@ -77,6 +107,16 @@ onMounted(async () => {
<Sidebar />
<main class="main">
<h2>✅ {{ L.t('task.title') }}</h2>
<div class="row wrap team-tabs">
<button :class="{ ghost: teamFilter !== '' }" @click="teamFilter = ''">🌐 {{ L.t('task.allTeams') }}</button>
<button
v-for="tm in teams" :key="tm.id"
:class="{ ghost: teamFilter !== tm.id }"
@click="teamFilter = tm.id"
>
{{ tm.icon }} {{ tm.name }}
</button>
</div>
<div class="card new-task">
<div class="row wrap">
<input v-model="title" :placeholder="L.t('task.ph')" @keyup.enter="create" style="flex: 2" />
@@ -96,6 +136,14 @@ onMounted(async () => {
<option value="">{{ L.t('task.page') }}: {{ L.t('task.none') }}</option>
<option v-for="p in ws.pages" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
<select v-model="teamId">
<option value="">{{ L.t('task.team') }}: {{ L.t('task.none') }}</option>
<option v-for="tm in teams" :key="tm.id" :value="tm.id">{{ tm.icon }} {{ tm.name }}</option>
</select>
<select v-model="assigneeId">
<option value="">{{ L.t('task.assignee') }}: {{ L.t('task.none') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
<button @click="create">+ {{ L.t('task.new') }}</button>
</div>
</div>
@@ -112,6 +160,14 @@ onMounted(async () => {
<div class="muted">
<span v-if="t.book_title">📚 {{ t.book_title }}</span>
<span v-if="t.page_title" class="link" @click="router.push(`/page/${t.page_id}`)">📄 {{ t.page_title }}</span>
<span v-if="t.team_name" class="pill">👥 {{ t.team_name }}</span>
</div>
<div class="row wrap">
<Avatar v-if="t.assignee_name" :name="t.assignee_name" :avatar="t.assignee_avatar" :size="22" />
<select :value="t.assignee_id || ''" @change="assign(t, $event.target.value)" style="max-width: 150px">
<option value="">{{ L.t('task.assignee') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div class="row wrap">
<button v-if="t.status !== 'todo'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.toTodo') }}</button>