Compare commits
6
Commits
663f7c16cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe56265c9 | ||
|
|
ab751ea7ef | ||
|
|
0bb4e5c1cd | ||
|
|
c3c77333db | ||
|
|
fb396d313a | ||
|
|
b852583499 |
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
uploads/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -12,8 +12,25 @@ light/dark mode. Self-hostable via Node.js or Docker Compose.
|
||||
- **Auth** — Register / login with JWT (access token), bcrypt password hashing.
|
||||
- **Workspaces** — Create workspaces, invite members by email (`owner` / `admin` /
|
||||
`member` / `viewer`), change roles, remove members.
|
||||
- **Pages** — Nested pages (`parent_id`), icon + cover, ordering, TipTap rich-text
|
||||
content stored as JSON + rendered HTML.
|
||||
- **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/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.
|
||||
- **Private chats** ✉️ — 1:1 DMs with reactions, edit, attachments, search, typing,
|
||||
online presence and unread badges; team chats listed per workspace.
|
||||
- **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
|
||||
(light/dark/system), language (**Deutsch, English, 中文**), background image + blur.
|
||||
- **Admin dashboard** 🛡️ — Stats, user management (admin flag, delete).
|
||||
- **User discovery** 🔎 — Fuzzy search to find and invite collaborators.
|
||||
- **Real-time collaboration** — Socket.IO rooms per page: live content broadcast,
|
||||
presence (who is viewing), typing indicators.
|
||||
- **Comments** — Threaded comments per page.
|
||||
|
||||
Generated
+163
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -76,6 +76,192 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
||||
-- Global admin flag (first user created via web installer)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Profile / appearance / locale settings (edited via settings window)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_image TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_blur BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- Books: independent containers that hold pages (collapsible in sidebar)
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT 'Untitled book' CHECK (char_length(title) BETWEEN 1 AND 200),
|
||||
icon TEXT NOT NULL DEFAULT '📚',
|
||||
position DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_books_workspace ON books(workspace_id);
|
||||
|
||||
-- Pages can optionally live inside a book (book_id NULL = top-level page)
|
||||
ALTER TABLE pages ADD COLUMN IF NOT EXISTS book_id UUID REFERENCES books(id) ON DELETE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_book ON pages(book_id);
|
||||
|
||||
-- Tasks: can link to a book and/or a page, optional deadline (feeds the calendar)
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
|
||||
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 300),
|
||||
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')),
|
||||
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high')),
|
||||
due_at TIMESTAMPTZ NULL,
|
||||
position DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(workspace_id, due_at);
|
||||
|
||||
-- Time tracking entries (open entry = running timer, ended_at IS NULL)
|
||||
CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
|
||||
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ NULL,
|
||||
duration_seconds INTEGER NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
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)
|
||||
);
|
||||
|
||||
-- Per-user permission overrides (explicit allow/deny beats group rights)
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL CHECK (permission IN ('teams.create','teams.edit','teams.invite','workspace.invite')),
|
||||
allow BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, user_id, permission)
|
||||
);
|
||||
|
||||
-- Chat attachments + edit timestamps
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ NULL;
|
||||
|
||||
-- Private 1:1 conversations (DMs)
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_members_user ON conversation_members(user_id);
|
||||
CREATE TABLE IF NOT EXISTS dm_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(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),
|
||||
attachment TEXT NOT NULL DEFAULT '',
|
||||
edited_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_conv ON dm_messages(conversation_id, created_at);
|
||||
|
||||
-- Emoji reactions on chat + DM messages (toggle per user+emoji)
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_type TEXT NOT NULL CHECK (target_type IN ('chat','dm')),
|
||||
target_id UUID NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (target_type, target_id, user_id, emoji)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_target ON reactions(target_type, target_id);
|
||||
|
||||
-- Ensure workspace owner is always a member with role=owner
|
||||
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
@@ -110,3 +296,11 @@ FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
DROP TRIGGER IF EXISTS trg_pages_touch ON pages;
|
||||
CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_books_touch ON books;
|
||||
CREATE TRIGGER trg_books_touch BEFORE UPDATE ON books
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tasks_touch ON tasks;
|
||||
CREATE TRIGGER trg_tasks_touch BEFORE UPDATE ON tasks
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
+28
-1
@@ -1,4 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
import 'express-async-errors';
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
@@ -11,6 +12,18 @@ import setupRoutes, { isInstalled } from './routes/setup.js';
|
||||
import workspaceRoutes from './routes/workspaces.js';
|
||||
import pageRoutes from './routes/pages.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
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 dmRoutes from './routes/dm.js';
|
||||
import reactionRoutes from './routes/reactions.js';
|
||||
import { initSocket } from './socket.js';
|
||||
|
||||
const app = express();
|
||||
@@ -43,6 +56,19 @@ app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRout
|
||||
app.use('/api/workspaces', workspaceRoutes);
|
||||
app.use('/api/pages', pageRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
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('/api/dm', dmRoutes);
|
||||
app.use('/api/reactions', reactionRoutes);
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
@@ -54,7 +80,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' });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../config/db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { requireAdmin } from './guards.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
// GET /api/admin/overview — counts + recent users
|
||||
router.get('/overview', async (_req, res) => {
|
||||
const [[u], [w], [b], [p], [t], [e]] = await Promise.all(
|
||||
['users', 'workspaces', 'books', 'pages', 'tasks', 'time_entries'].map(
|
||||
(tbl) => query(`SELECT COUNT(*)::int AS c FROM ${tbl}`).then((r) => r.rows),
|
||||
),
|
||||
);
|
||||
const { rows: recent } = await query(
|
||||
'SELECT id, name, email, is_admin, created_at FROM users ORDER BY created_at DESC LIMIT 8',
|
||||
);
|
||||
return res.json({
|
||||
counts: {
|
||||
users: u.c, workspaces: w.c, books: b.c, pages: p.c, tasks: t.c, timeEntries: e.c,
|
||||
},
|
||||
recentUsers: recent,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/admin/users
|
||||
router.get('/users', async (_req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT u.id, u.name, u.email, u.is_admin, u.locale, u.created_at,
|
||||
(SELECT COUNT(*)::int FROM workspace_members m WHERE m.user_id = u.id) AS workspaces
|
||||
FROM users u ORDER BY u.created_at`,
|
||||
);
|
||||
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 ?? {};
|
||||
if (typeof is_admin !== 'boolean') return res.status(400).json({ error: 'is_admin boolean required' });
|
||||
if (req.params.id === req.user.id && is_admin === false) {
|
||||
return res.status(400).json({ error: 'You cannot demote yourself' });
|
||||
}
|
||||
const { rows } = await query(
|
||||
'UPDATE users SET is_admin = $1 WHERE id = $2 RETURNING id, name, email, is_admin',
|
||||
[is_admin, req.params.id],
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
|
||||
return res.json({ user: rows[0] });
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:id
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
if (req.params.id === req.user.id) {
|
||||
return res.status(400).json({ error: 'You cannot delete yourself' });
|
||||
}
|
||||
const { rowCount } = await query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'User not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,11 +7,29 @@ const router = Router();
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
function publicUser(row) {
|
||||
return { id: row.id, email: row.email, name: row.name, isAdmin: !!row.is_admin, createdAt: row.created_at };
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
isAdmin: !!row.is_admin,
|
||||
avatar: row.avatar || '',
|
||||
locale: row.locale || 'en',
|
||||
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' });
|
||||
@@ -27,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, 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]);
|
||||
@@ -57,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, 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]) });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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/books/by-workspace/:workspaceId
|
||||
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 * FROM books WHERE workspace_id = $1 ORDER BY position, updated_at DESC',
|
||||
[req.params.workspaceId],
|
||||
);
|
||||
return res.json({ books: rows });
|
||||
});
|
||||
|
||||
// POST /api/books { workspace_id, title?, icon? } (member+)
|
||||
router.post('/', async (req, res) => {
|
||||
const { workspace_id, title = 'Untitled book', icon = '📚' } = req.body ?? {};
|
||||
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||
const role = await getRole(workspace_id, req.user.id);
|
||||
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||
return res.status(403).json({ error: 'Viewers cannot create books' });
|
||||
}
|
||||
const { rows } = await query(
|
||||
'INSERT INTO books (workspace_id, title, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||
[workspace_id, String(title).slice(0, 200) || 'Untitled book', String(icon).slice(0, 20) || '📚', req.user.id],
|
||||
);
|
||||
return res.status(201).json({ book: rows[0] });
|
||||
});
|
||||
|
||||
// PATCH /api/books/:id { title?, icon?, position? } (member+)
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
|
||||
const book = found[0];
|
||||
if (!book) return res.status(404).json({ error: 'Not found' });
|
||||
const role = await getRole(book.workspace_id, req.user.id);
|
||||
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||
return res.status(403).json({ error: 'Viewers cannot edit' });
|
||||
}
|
||||
const { title, icon, position } = req.body ?? {};
|
||||
const { rows } = await query(
|
||||
`UPDATE books SET title = COALESCE($1, title), icon = COALESCE($2, icon),
|
||||
position = COALESCE($3, position) WHERE id = $4 RETURNING *`,
|
||||
[
|
||||
title !== undefined ? String(title).slice(0, 200) : null,
|
||||
icon !== undefined ? String(icon).slice(0, 20) : null,
|
||||
position !== undefined ? Number(position) : null,
|
||||
book.id,
|
||||
],
|
||||
);
|
||||
return res.json({ book: rows[0] });
|
||||
});
|
||||
|
||||
// DELETE /api/books/:id (member+; pages inside are removed via FK cascade)
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
|
||||
const book = found[0];
|
||||
if (!book) return res.status(404).json({ error: 'Not found' });
|
||||
const role = await getRole(book.workspace_id, req.user.id);
|
||||
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||
return res.status(403).json({ error: 'Viewers cannot delete' });
|
||||
}
|
||||
await query('DELETE FROM books WHERE id = $1', [book.id]);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
|
||||
async function attachReactions(messages) {
|
||||
if (!messages.length) return messages;
|
||||
const ids = messages.map((m) => m.id);
|
||||
const { rows } = await query(
|
||||
`SELECT target_id, emoji, COUNT(*)::int AS count, array_agg(user_id) AS users
|
||||
FROM reactions WHERE target_type = 'chat' AND target_id = ANY($1)
|
||||
GROUP BY target_id, emoji`,
|
||||
[ids],
|
||||
);
|
||||
const byMsg = {};
|
||||
for (const r of rows) {
|
||||
if (!byMsg[r.target_id]) byMsg[r.target_id] = [];
|
||||
byMsg[r.target_id].push({ emoji: r.emoji, count: r.count, users: r.users });
|
||||
}
|
||||
return messages.map((m) => ({ ...m, reactions: byMsg[m.id] || [] }));
|
||||
}
|
||||
|
||||
// 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],
|
||||
);
|
||||
const withReactions = await attachReactions(rows.reverse());
|
||||
return res.json({ messages: withReactions });
|
||||
});
|
||||
|
||||
// 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 = '', attachment = '' } = 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, attachment) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||
[wsId, req.user.id, String(body).trim(), String(attachment).slice(0, 2000)],
|
||||
);
|
||||
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, reactions: [] };
|
||||
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 });
|
||||
});
|
||||
|
||||
// PATCH /api/chat/message/:id { body } (own)
|
||||
router.patch('/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' });
|
||||
if (msg.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||
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: updated } = await query(
|
||||
'UPDATE chat_messages SET body = $1, edited_at = now() WHERE id = $2 RETURNING *',
|
||||
[String(body).trim(), msg.id],
|
||||
);
|
||||
req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:edited', { message: updated[0] });
|
||||
return res.json({ message: updated[0] });
|
||||
});
|
||||
|
||||
// GET /api/chat/:workspaceId/search?q=
|
||||
router.get('/:workspaceId/search', 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 q = String(req.query.q || '').slice(0, 100);
|
||||
if (q.length < 2) return res.json({ messages: [] });
|
||||
const { rows } = await query(
|
||||
`SELECT m.*, u.name AS author_name FROM chat_messages m
|
||||
LEFT JOIN users u ON u.id = m.user_id
|
||||
WHERE m.workspace_id = $1 AND m.body ILIKE $2
|
||||
ORDER BY m.created_at DESC LIMIT 30`,
|
||||
[req.params.workspaceId, `%${q}%`],
|
||||
);
|
||||
return res.json({ messages: rows });
|
||||
});
|
||||
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;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../config/db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
async function isMember(conversationId, userId) {
|
||||
const { rows } = await query(
|
||||
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||
[conversationId, userId],
|
||||
);
|
||||
return !!rows[0];
|
||||
}
|
||||
|
||||
async function withReactions(type, messages) {
|
||||
if (!messages.length) return messages;
|
||||
const ids = messages.map((m) => m.id);
|
||||
const { rows } = await query(
|
||||
`SELECT target_id, emoji, COUNT(*)::int AS count,
|
||||
array_agg(user_id) AS users
|
||||
FROM reactions WHERE target_type = $1 AND target_id = ANY($2)
|
||||
GROUP BY target_id, emoji`,
|
||||
[type, ids],
|
||||
);
|
||||
const byMsg = {};
|
||||
for (const r of rows) {
|
||||
if (!byMsg[r.target_id]) byMsg[r.target_id] = [];
|
||||
byMsg[r.target_id].push({ emoji: r.emoji, count: r.count, users: r.users });
|
||||
}
|
||||
return messages.map((m) => ({ ...m, reactions: byMsg[m.id] || [] }));
|
||||
}
|
||||
|
||||
// GET /api/dm — my conversations (peer, last message, unread)
|
||||
router.get('/', async (req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT c.id, c.created_at,
|
||||
(SELECT json_build_object('id', u.id, 'name', u.name, 'email', u.email, 'avatar', COALESCE(u.avatar,''))
|
||||
FROM conversation_members cm2 JOIN users u ON u.id = cm2.user_id
|
||||
WHERE cm2.conversation_id = c.id AND cm2.user_id <> $1 LIMIT 1) AS peer,
|
||||
(SELECT json_build_object('body', m.body, 'created_at', m.created_at, 'user_id', m.user_id)
|
||||
FROM dm_messages m WHERE m.conversation_id = c.id
|
||||
ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT COUNT(*)::int FROM dm_messages m
|
||||
JOIN conversation_members me ON me.conversation_id = c.id AND me.user_id = $1
|
||||
WHERE m.conversation_id = c.id AND m.created_at > me.last_read_at AND m.user_id <> $1) AS unread
|
||||
FROM conversations c
|
||||
JOIN conversation_members me ON me.conversation_id = c.id AND me.user_id = $1
|
||||
ORDER BY COALESCE((SELECT MAX(created_at) FROM dm_messages WHERE conversation_id = c.id), c.created_at) DESC`,
|
||||
[req.user.id],
|
||||
);
|
||||
return res.json({ conversations: rows });
|
||||
});
|
||||
|
||||
// POST /api/dm { user_id } — find-or-create 1:1 conversation
|
||||
router.post('/', async (req, res) => {
|
||||
const { user_id } = req.body ?? {};
|
||||
if (!user_id || user_id === req.user.id) return res.status(400).json({ error: 'Valid user_id required' });
|
||||
const { rows: target } = await query('SELECT id, name, email, avatar FROM users WHERE id = $1', [user_id]);
|
||||
if (!target[0]) return res.status(404).json({ error: 'User not found' });
|
||||
const { rows: existing } = await query(
|
||||
`SELECT c.id FROM conversations c
|
||||
JOIN conversation_members a ON a.conversation_id = c.id AND a.user_id = $1
|
||||
JOIN conversation_members b ON b.conversation_id = c.id AND b.user_id = $2
|
||||
WHERE (SELECT COUNT(*) FROM conversation_members WHERE conversation_id = c.id) = 2
|
||||
LIMIT 1`,
|
||||
[req.user.id, user_id],
|
||||
);
|
||||
if (existing[0]) return res.json({ conversation: { id: existing[0].id } });
|
||||
const { rows } = await query('INSERT INTO conversations (created_by) VALUES ($1) RETURNING id', [req.user.id]);
|
||||
await query('INSERT INTO conversation_members (conversation_id, user_id) VALUES ($1,$2),($1,$3)', [
|
||||
rows[0].id, req.user.id, user_id,
|
||||
]);
|
||||
return res.status(201).json({ conversation: { id: rows[0].id } });
|
||||
});
|
||||
|
||||
// GET /api/dm/:id/messages?limit=100 — marks as read
|
||||
router.get('/:id/messages', async (req, res) => {
|
||||
if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||
const limit = Math.min(Number(req.query.limit) || 100, 200);
|
||||
const { rows } = await query(
|
||||
`SELECT m.*, u.name AS author_name, u.avatar AS author_avatar
|
||||
FROM dm_messages m LEFT JOIN users u ON u.id = m.user_id
|
||||
WHERE m.conversation_id = $1 ORDER BY m.created_at DESC LIMIT $2`,
|
||||
[req.params.id, limit],
|
||||
);
|
||||
const messages = (await withReactions('dm', rows)).reverse();
|
||||
await query(
|
||||
'UPDATE conversation_members SET last_read_at = now() WHERE conversation_id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
return res.json({ messages });
|
||||
});
|
||||
|
||||
// GET /api/dm/:id/search?q=
|
||||
router.get('/:id/search', async (req, res) => {
|
||||
if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||
const q = String(req.query.q || '').slice(0, 100);
|
||||
if (q.length < 2) return res.json({ messages: [] });
|
||||
const { rows } = await query(
|
||||
`SELECT m.*, u.name AS author_name FROM dm_messages m
|
||||
LEFT JOIN users u ON u.id = m.user_id
|
||||
WHERE m.conversation_id = $1 AND m.body ILIKE $2
|
||||
ORDER BY m.created_at DESC LIMIT 30`,
|
||||
[req.params.id, `%${q}%`],
|
||||
);
|
||||
return res.json({ messages: rows });
|
||||
});
|
||||
|
||||
// POST /api/dm/:id/messages { body, attachment? }
|
||||
router.post('/:id/messages', async (req, res) => {
|
||||
if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||
const { body = '', attachment = '' } = 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 dm_messages (conversation_id, user_id, body, attachment) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||
[req.params.id, req.user.id, String(body).trim(), String(attachment).slice(0, 2000)],
|
||||
);
|
||||
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, reactions: [] };
|
||||
const io = req.app.get('io');
|
||||
io?.to(`dm:${req.params.id}`).emit('dm:message', { message: msg });
|
||||
// Notify the other member
|
||||
const { rows: others } = await query(
|
||||
'SELECT user_id FROM conversation_members WHERE conversation_id = $1 AND user_id <> $2',
|
||||
[req.params.id, req.user.id],
|
||||
);
|
||||
for (const o of others) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, body, link)
|
||||
VALUES ($1,'chat',$2,$3,$4)`,
|
||||
[o.user_id, `✉️ ${author[0]?.name || 'Someone'}`, String(body).trim().slice(0, 140), '/chat'],
|
||||
);
|
||||
io?.to(`user:${o.user_id}`).emit('notification:new', { type: 'dm' });
|
||||
}
|
||||
return res.status(201).json({ message: msg });
|
||||
});
|
||||
|
||||
// PATCH /api/dm/message/:mid { body } (own)
|
||||
router.patch('/message/:mid', async (req, res) => {
|
||||
const { rows } = await query('SELECT * FROM dm_messages WHERE id = $1', [req.params.mid]);
|
||||
const msg = rows[0];
|
||||
if (!msg) return res.status(404).json({ error: 'Not found' });
|
||||
if (msg.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||
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: updated } = await query(
|
||||
'UPDATE dm_messages SET body = $1, edited_at = now() WHERE id = $2 RETURNING *',
|
||||
[String(body).trim(), msg.id],
|
||||
);
|
||||
req.app.get('io')?.to(`dm:${msg.conversation_id}`).emit('dm:edited', { message: updated[0] });
|
||||
return res.json({ message: updated[0] });
|
||||
});
|
||||
|
||||
// DELETE /api/dm/message/:mid (own or admin)
|
||||
router.delete('/message/:mid', async (req, res) => {
|
||||
const { rows } = await query('SELECT * FROM dm_messages WHERE id = $1', [req.params.mid]);
|
||||
const msg = rows[0];
|
||||
if (!msg) return res.status(404).json({ error: 'Not found' });
|
||||
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) return res.status(403).json({ error: 'Forbidden' });
|
||||
await query('DELETE FROM dm_messages WHERE id = $1', [msg.id]);
|
||||
req.app.get('io')?.to(`dm:${msg.conversation_id}`).emit('dm:deleted', { id: msg.id });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -32,3 +31,53 @@ export async function getPageWithWorkspace(pageId) {
|
||||
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/** Require global admin flag (set via web installer / admin dashboard). */
|
||||
export async function requireAdmin(req, res, next) {
|
||||
const { rows } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||
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.
|
||||
* owner/admin always pass; explicit per-user allow/deny beats group rights. */
|
||||
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: override } = await query(
|
||||
'SELECT allow FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3',
|
||||
[workspaceId, userId, perm],
|
||||
);
|
||||
if (override[0]) return !!override[0].allow;
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -30,24 +30,31 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
|
||||
return res.json({ pages: rows, role });
|
||||
});
|
||||
|
||||
// POST /api/pages { workspace_id, parent_id?, title? }
|
||||
// POST /api/pages { workspace_id, parent_id?, book_id?, title? }
|
||||
router.post('/', async (req, res) => {
|
||||
const { workspace_id, parent_id = null, title = 'Untitled' } = req.body ?? {};
|
||||
const { workspace_id, parent_id = null, book_id = null, title = 'Untitled' } = req.body ?? {};
|
||||
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||
const role = await getRole(workspace_id, req.user.id);
|
||||
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||
return res.status(403).json({ error: 'Viewers cannot create pages' });
|
||||
}
|
||||
if (book_id) {
|
||||
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspace_id]);
|
||||
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
|
||||
}
|
||||
if (parent_id) {
|
||||
const parent = await getPageWithWorkspace(parent_id);
|
||||
if (!parent || parent.workspace_id !== workspace_id) {
|
||||
return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
}
|
||||
if ((parent.book_id || null) !== (book_id || null)) {
|
||||
return res.status(400).json({ error: 'Parent lives in a different book' });
|
||||
}
|
||||
}
|
||||
const { rows } = await query(
|
||||
`INSERT INTO pages (workspace_id, parent_id, title, created_by)
|
||||
VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[workspace_id, parent_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
|
||||
`INSERT INTO pages (workspace_id, parent_id, book_id, title, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[workspace_id, parent_id, book_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
|
||||
);
|
||||
return res.status(201).json({ page: rows[0] });
|
||||
});
|
||||
@@ -71,7 +78,7 @@ router.patch('/:id', async (req, res) => {
|
||||
const page = await getPageWithWorkspace(req.params.id);
|
||||
if (!page) return res.status(404).json({ error: 'Not found' });
|
||||
if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' });
|
||||
const { title, icon, cover, content_json, content_html, position, parent_id, is_archived } = req.body ?? {};
|
||||
const { title, icon, cover, content_json, content_html, position, parent_id, book_id, is_archived } = req.body ?? {};
|
||||
if (parent_id !== undefined && parent_id !== null) {
|
||||
if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' });
|
||||
const parent = await getPageWithWorkspace(parent_id);
|
||||
@@ -79,6 +86,10 @@ router.patch('/:id', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
}
|
||||
}
|
||||
if (book_id !== undefined && book_id !== null) {
|
||||
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, page.workspace_id]);
|
||||
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
|
||||
}
|
||||
const { rows } = await query(
|
||||
`UPDATE pages SET
|
||||
title = COALESCE($1, title),
|
||||
@@ -88,6 +99,7 @@ router.patch('/:id', async (req, res) => {
|
||||
content_html = COALESCE($5, content_html),
|
||||
position = COALESCE($6, position),
|
||||
parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END,
|
||||
book_id = CASE WHEN $12 THEN $11 ELSE book_id END,
|
||||
is_archived = COALESCE($9, is_archived)
|
||||
WHERE id = $10 RETURNING *`,
|
||||
[
|
||||
@@ -101,6 +113,8 @@ router.patch('/:id', async (req, res) => {
|
||||
parent_id !== undefined,
|
||||
is_archived !== undefined ? Boolean(is_archived) : null,
|
||||
page.id,
|
||||
book_id !== undefined ? book_id : null,
|
||||
book_id !== undefined,
|
||||
],
|
||||
);
|
||||
const updated = rows[0];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
|
||||
async function canSee(type, targetId, userId) {
|
||||
if (type === 'chat') {
|
||||
const { rows } = await query('SELECT workspace_id FROM chat_messages WHERE id = $1', [targetId]);
|
||||
if (!rows[0]) return null;
|
||||
const role = await getRole(rows[0].workspace_id, userId);
|
||||
return role ? { room: `chat:${rows[0].workspace_id}` } : null;
|
||||
}
|
||||
const { rows } = await query('SELECT conversation_id FROM dm_messages WHERE id = $1', [targetId]);
|
||||
if (!rows[0]) return null;
|
||||
const { rows: m } = await query(
|
||||
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||
[rows[0].conversation_id, userId],
|
||||
);
|
||||
return m[0] ? { room: `dm:${rows[0].conversation_id}` } : null;
|
||||
}
|
||||
|
||||
// POST /api/reactions/toggle { target_type: 'chat'|'dm', target_id, emoji }
|
||||
router.post('/toggle', async (req, res) => {
|
||||
const { target_type, target_id, emoji = '' } = req.body ?? {};
|
||||
if (!['chat', 'dm'].includes(target_type)) return res.status(400).json({ error: 'Invalid target' });
|
||||
const e = String(emoji).slice(0, 20);
|
||||
if (!e) return res.status(400).json({ error: 'Emoji required' });
|
||||
const access = await canSee(target_type, target_id, req.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Message not found' });
|
||||
const { rows: existing } = await query(
|
||||
'SELECT id FROM reactions WHERE target_type = $1 AND target_id = $2 AND user_id = $3 AND emoji = $4',
|
||||
[target_type, target_id, req.user.id, e],
|
||||
);
|
||||
let added;
|
||||
if (existing[0]) {
|
||||
await query('DELETE FROM reactions WHERE id = $1', [existing[0].id]);
|
||||
added = false;
|
||||
} else {
|
||||
await query(
|
||||
'INSERT INTO reactions (target_type, target_id, user_id, emoji) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING',
|
||||
[target_type, target_id, req.user.id, e],
|
||||
);
|
||||
added = true;
|
||||
}
|
||||
const { rows: counts } = await query(
|
||||
`SELECT emoji, COUNT(*)::int AS count, array_agg(user_id) AS users
|
||||
FROM reactions WHERE target_type = $1 AND target_id = $2 GROUP BY emoji`,
|
||||
[target_type, target_id],
|
||||
);
|
||||
req.app.get('io')?.to(access.room).emit('reaction:updated', {
|
||||
target_type, target_id, reactions: counts, by: req.user.id, added, emoji: e,
|
||||
});
|
||||
return res.json({ added, reactions: counts });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,152 @@
|
||||
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);
|
||||
const STATUSES = ['todo', 'doing', 'done'];
|
||||
const PRIORITIES = ['low', 'normal', 'high'];
|
||||
|
||||
async function canWrite(userId, workspaceId) {
|
||||
const role = await getRole(workspaceId, userId);
|
||||
return role === 'owner' || role === 'admin' || role === 'member';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
if (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=&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, team_id, assignee } = req.query;
|
||||
const conds = ['t.workspace_id = $1'];
|
||||
const params = [req.params.workspaceId];
|
||||
if (status && STATUSES.includes(String(status))) {
|
||||
params.push(String(status));
|
||||
conds.push(`t.status = $${params.length}`);
|
||||
}
|
||||
if (from) {
|
||||
params.push(new Date(String(from)));
|
||||
conds.push(`t.due_at >= $${params.length}`);
|
||||
}
|
||||
if (to) {
|
||||
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,
|
||||
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?, team_id?, assignee_id?, title, ... }
|
||||
router.post('/', async (req, res) => {
|
||||
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' });
|
||||
}
|
||||
if (String(title).trim().length < 1 || String(title).length > 300) {
|
||||
return res.status(400).json({ error: 'Title must be 1-300 chars' });
|
||||
}
|
||||
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, 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, 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] });
|
||||
});
|
||||
|
||||
// PATCH /api/tasks/:id
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
|
||||
const task = found[0];
|
||||
if (!task) return res.status(404).json({ error: 'Not found' });
|
||||
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, 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)) {
|
||||
return res.status(400).json({ error: 'Title must be 1-300 chars' });
|
||||
}
|
||||
const nextBook = book_id !== undefined ? book_id : task.book_id;
|
||||
const nextPage = page_id !== undefined ? page_id : task.page_id;
|
||||
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 = [];
|
||||
const push = (col, val) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
||||
if (title !== undefined) push('title', String(title).trim());
|
||||
if (status !== undefined) push('status', status);
|
||||
if (priority !== undefined) push('priority', priority);
|
||||
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);
|
||||
const { rows } = await query(
|
||||
`UPDATE tasks SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return res.json({ task: rows[0] });
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
|
||||
const task = found[0];
|
||||
if (!task) return res.status(404).json({ error: 'Not found' });
|
||||
if (!(await canWrite(req.user.id, task.workspace_id))) {
|
||||
return res.status(403).json({ error: 'Viewers cannot delete' });
|
||||
}
|
||||
await query('DELETE FROM tasks WHERE id = $1', [task.id]);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,116 @@
|
||||
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);
|
||||
|
||||
function serialize(row) {
|
||||
if (!row) return row;
|
||||
return row;
|
||||
}
|
||||
|
||||
// GET /api/time/by-workspace/:workspaceId — entries + currently running timer
|
||||
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 mine = req.query.mine === '1';
|
||||
const params = [req.params.workspaceId];
|
||||
let cond = 'e.workspace_id = $1';
|
||||
if (mine) {
|
||||
params.push(req.user.id);
|
||||
cond += ` AND e.user_id = $${params.length}`;
|
||||
}
|
||||
const { rows } = await query(
|
||||
`SELECT e.*, b.title AS book_title, p.title AS page_title, u.name AS user_name
|
||||
FROM time_entries e
|
||||
LEFT JOIN books b ON b.id = e.book_id
|
||||
LEFT JOIN pages p ON p.id = e.page_id
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
WHERE ${cond} ORDER BY e.started_at DESC LIMIT 200`,
|
||||
params,
|
||||
);
|
||||
const open = rows.find((r) => r.user_id === req.user.id && !r.ended_at) || null;
|
||||
return res.json({ entries: rows, open });
|
||||
});
|
||||
|
||||
// POST /api/time/start { workspace_id, book_id?, page_id?, description? }
|
||||
router.post('/start', async (req, res) => {
|
||||
const { workspace_id, book_id = null, page_id = null, description = '' } = req.body ?? {};
|
||||
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||
const role = await getRole(workspace_id, req.user.id);
|
||||
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||
const { rows: running } = await query(
|
||||
'SELECT id FROM time_entries WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL',
|
||||
[workspace_id, req.user.id],
|
||||
);
|
||||
if (running[0]) return res.status(409).json({ error: 'Timer already running', entry: running[0] });
|
||||
const { rows } = await query(
|
||||
`INSERT INTO time_entries (workspace_id, user_id, book_id, page_id, description)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[workspace_id, req.user.id, book_id, page_id, String(description).slice(0, 500)],
|
||||
);
|
||||
return res.status(201).json({ entry: serialize(rows[0]) });
|
||||
});
|
||||
|
||||
// POST /api/time/stop { workspace_id } — stops my running timer
|
||||
router.post('/stop', async (req, res) => {
|
||||
const { workspace_id } = req.body ?? {};
|
||||
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||
const { rows } = await query(
|
||||
`UPDATE time_entries SET ended_at = now(),
|
||||
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
|
||||
WHERE id = (
|
||||
SELECT id FROM time_entries
|
||||
WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
) RETURNING *`,
|
||||
[workspace_id, req.user.id],
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'No running timer' });
|
||||
return res.json({ entry: rows[0] });
|
||||
});
|
||||
|
||||
// PATCH /api/time/:id { description?, book_id?, page_id? } (own or admin+)
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
|
||||
const entry = found[0];
|
||||
if (!entry) return res.status(404).json({ error: 'Not found' });
|
||||
const role = await getRole(entry.workspace_id, req.user.id);
|
||||
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const { description, book_id, page_id } = req.body ?? {};
|
||||
const { rows } = await query(
|
||||
`UPDATE time_entries SET description = COALESCE($1, description),
|
||||
book_id = CASE WHEN $4 THEN $2 ELSE book_id END,
|
||||
page_id = CASE WHEN $5 THEN $3 ELSE page_id END
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[
|
||||
description !== undefined ? String(description).slice(0, 500) : null,
|
||||
book_id !== undefined ? book_id : null,
|
||||
page_id !== undefined ? page_id : null,
|
||||
book_id !== undefined, page_id !== undefined,
|
||||
entry.id,
|
||||
],
|
||||
);
|
||||
return res.json({ entry: rows[0] });
|
||||
});
|
||||
|
||||
// DELETE /api/time/:id (own or admin+)
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
|
||||
const entry = found[0];
|
||||
if (!entry) return res.status(404).json({ error: 'Not found' });
|
||||
const role = await getRole(entry.workspace_id, req.user.id);
|
||||
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
await query('DELETE FROM time_entries WHERE id = $1', [entry.id]);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
+100
-4
@@ -1,18 +1,114 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query } from '../config/db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/users/search?email= — find user by email (for invites)
|
||||
const LOCALES = ['de', 'en', 'zh'];
|
||||
const THEMES = ['light', 'dark', 'system'];
|
||||
|
||||
// GET /api/users/search?email= — find user by exact email (for invites)
|
||||
router.get('/search', async (req, res) => {
|
||||
const { email = '' } = req.query;
|
||||
if (!email) return res.status(400).json({ error: 'email required' });
|
||||
const { rows } = await query('SELECT id, email, name FROM users WHERE email = $1', [
|
||||
String(email).toLowerCase(),
|
||||
]);
|
||||
const { rows } = await query(
|
||||
'SELECT id, email, name, avatar FROM users WHERE email = $1',
|
||||
[String(email).toLowerCase()],
|
||||
);
|
||||
return res.json({ user: rows[0] || null });
|
||||
});
|
||||
|
||||
// GET /api/users/discover?q= — fuzzy user discovery for invites/mentions.
|
||||
// Returns id, name, email, avatar (never password hashes).
|
||||
router.get('/discover', async (req, res) => {
|
||||
const q = String(req.query.q || '').trim().slice(0, 60);
|
||||
if (q.length < 2) return res.json({ users: [] });
|
||||
const { rows } = await query(
|
||||
`SELECT id, name, email, avatar FROM users
|
||||
WHERE name ILIKE $1 OR email ILIKE $1
|
||||
ORDER BY name LIMIT 10`,
|
||||
[`%${q}%`],
|
||||
);
|
||||
return res.json({ users: rows });
|
||||
});
|
||||
|
||||
// PATCH /api/users/me — profile + appearance settings
|
||||
router.patch('/me', async (req, res) => {
|
||||
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' });
|
||||
}
|
||||
if (email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email))) {
|
||||
return res.status(400).json({ error: 'Valid email required' });
|
||||
}
|
||||
if (locale !== undefined && !LOCALES.includes(locale)) {
|
||||
return res.status(400).json({ error: 'Locale must be de, en or zh' });
|
||||
}
|
||||
if (theme !== undefined && !THEMES.includes(theme)) {
|
||||
return res.status(400).json({ error: 'Theme must be light, dark or system' });
|
||||
}
|
||||
if (avatar !== undefined && String(avatar).length > 500) {
|
||||
return res.status(400).json({ error: 'Avatar too long (max 500 chars, emoji or URL)' });
|
||||
}
|
||||
if (bg_image !== undefined && String(bg_image).length > 2000) {
|
||||
return res.status(400).json({ error: 'Background URL too long' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await query(
|
||||
`UPDATE users SET
|
||||
name = COALESCE($1, name),
|
||||
email = COALESCE($2, email),
|
||||
avatar = COALESCE($3, avatar),
|
||||
locale = COALESCE($4, locale),
|
||||
theme = COALESCE($5, theme),
|
||||
bg_image = COALESCE($6, bg_image),
|
||||
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,
|
||||
avatar !== undefined ? String(avatar).slice(0, 500) : null,
|
||||
locale !== undefined ? locale : null,
|
||||
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,
|
||||
],
|
||||
);
|
||||
const u = rows[0];
|
||||
return res.json({
|
||||
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,
|
||||
bgGlass: u.bg_glass !== false, createdAt: u.created_at,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Email already taken' });
|
||||
console.error('[users/me]', err.message);
|
||||
return res.status(500).json({ error: 'Update failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/users/me/password { currentPassword, newPassword }
|
||||
router.patch('/me/password', async (req, res) => {
|
||||
const { currentPassword = '', newPassword = '' } = req.body ?? {};
|
||||
if (String(newPassword).length < 8) {
|
||||
return res.status(400).json({ error: 'New password must be at least 8 chars' });
|
||||
}
|
||||
const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
|
||||
const ok = await bcrypt.compare(String(currentPassword), rows[0].password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Current password is wrong' });
|
||||
const hash = await bcrypt.hash(String(newPassword), 12);
|
||||
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.id]);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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, PERMS, 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] });
|
||||
});
|
||||
|
||||
@@ -59,7 +62,7 @@ router.delete('/:id', requireWorkspace('owner'), async (req, res) => {
|
||||
// GET /api/workspaces/:id/members
|
||||
router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT u.id, u.email, u.name, m.role, m.created_at
|
||||
`SELECT u.id, u.email, u.name, u.avatar, m.role, m.created_at
|
||||
FROM workspace_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.workspace_id = $1 ORDER BY m.created_at`,
|
||||
[req.params.id],
|
||||
@@ -67,20 +70,86 @@ 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],
|
||||
);
|
||||
return res.status(201).json({ ok: true });
|
||||
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],
|
||||
);
|
||||
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] });
|
||||
});
|
||||
|
||||
// GET /api/workspaces/:id/permissions — members with explicit overrides (admin+)
|
||||
router.get('/:id/permissions', requireWorkspace('admin'), async (req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT m.user_id, u.name, u.email, m.role,
|
||||
(SELECT json_object_agg(permission, allow) FROM user_permissions
|
||||
WHERE workspace_id = $1 AND user_id = m.user_id) AS overrides
|
||||
FROM workspace_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.workspace_id = $1 ORDER BY u.name`,
|
||||
[req.params.id],
|
||||
);
|
||||
return res.json({ members: rows });
|
||||
});
|
||||
|
||||
// PATCH /api/workspaces/:id/permissions { user_id, permission, mode }
|
||||
// mode: 'allow' | 'deny' | 'inherit' (inherit removes the override) (admin+)
|
||||
router.patch('/:id/permissions', requireWorkspace('admin'), async (req, res) => {
|
||||
const { user_id, permission, mode } = req.body ?? {};
|
||||
if (!PERMS.includes(permission)) return res.status(400).json({ error: 'Invalid permission' });
|
||||
if (!['allow', 'deny', 'inherit'].includes(mode)) return res.status(400).json({ error: 'Invalid mode' });
|
||||
if (!(await getRole(req.params.id, user_id))) {
|
||||
return res.status(400).json({ error: 'User must be a workspace member' });
|
||||
}
|
||||
if (mode === 'inherit') {
|
||||
await query(
|
||||
'DELETE FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3',
|
||||
[req.params.id, user_id, permission],
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`INSERT INTO user_permissions (workspace_id, user_id, permission, allow)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (workspace_id, user_id, permission) DO UPDATE SET allow = EXCLUDED.allow`,
|
||||
[req.params.id, user_id, permission, mode === 'allow'],
|
||||
);
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
|
||||
|
||||
@@ -30,6 +30,7 @@ export function initSocket(httpServer, corsOrigin) {
|
||||
});
|
||||
|
||||
const presence = new Map(); // room -> Map(socketId -> { userId, email, name })
|
||||
const online = new Map(); // userId -> connection count (global presence)
|
||||
|
||||
function emitPresence(room) {
|
||||
const members = [...(presence.get(room)?.values() ?? [])];
|
||||
@@ -38,6 +39,25 @@ export function initSocket(httpServer, corsOrigin) {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = socket.data.user;
|
||||
// Personal room for notifications (bell).
|
||||
socket.join(`user:${user.id}`);
|
||||
// Global online presence.
|
||||
online.set(user.id, (online.get(user.id) || 0) + 1);
|
||||
io.emit('presence:online', [...online.keys()]);
|
||||
|
||||
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 {
|
||||
@@ -94,10 +114,36 @@ export function initSocket(httpServer, corsOrigin) {
|
||||
socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name });
|
||||
});
|
||||
|
||||
socket.on('dm:join', async ({ conversationId }) => {
|
||||
try {
|
||||
if (!conversationId) return;
|
||||
const { rows } = await query(
|
||||
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||
[conversationId, user.id],
|
||||
);
|
||||
if (!rows[0]) return socket.emit('error:message', { error: 'Forbidden' });
|
||||
socket.join(`dm:${conversationId}`);
|
||||
} catch {
|
||||
socket.emit('error:message', { error: 'Join failed' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('chat:typing', ({ workspaceId, name }) => {
|
||||
socket.to(`chat:${workspaceId}`).emit('chat:typing', { userId: user.id, name });
|
||||
});
|
||||
|
||||
socket.on('dm:typing', ({ conversationId, name }) => {
|
||||
socket.to(`dm:${conversationId}`).emit('dm:typing', { userId: user.id, name });
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
for (const [room, members] of presence) {
|
||||
if (members.delete(socket.id)) emitPresence(room);
|
||||
}
|
||||
const left = (online.get(user.id) || 1) - 1;
|
||||
if (left <= 0) online.delete(user.id);
|
||||
else online.set(user.id, left);
|
||||
io.emit('presence:online', [...online.keys()]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+52
@@ -59,6 +59,58 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML).
|
||||
## Users
|
||||
|
||||
- `GET /users/search?email=` — lookup for invites (auth required).
|
||||
- `GET /users/discover?q=` — fuzzy discovery (min 2 chars, max 10 hits, no hashes).
|
||||
- `PATCH /users/me` — profile/appearance (`name, email, avatar, locale, theme, bg_image, bg_blur`).
|
||||
- `PATCH /users/me/password` — `{ currentPassword, newPassword }`.
|
||||
|
||||
## Books
|
||||
|
||||
- `GET /books/by-workspace/:workspaceId`, `POST /books {workspace_id,title?,icon?}`
|
||||
- `PATCH /books/:id`, `DELETE /books/:id` (pages inside cascade).
|
||||
|
||||
## Tasks
|
||||
|
||||
- `GET /tasks/by-workspace/:workspaceId?status=&from=&to=` (calendar range on `due_at`)
|
||||
- `POST /tasks {workspace_id, book_id?, page_id?, title, status?, priority?, due_at?}`
|
||||
- `PATCH /tasks/:id`, `DELETE /tasks/:id`
|
||||
|
||||
## Time tracking
|
||||
|
||||
- `GET /time/by-workspace/:workspaceId` → `{ entries, open }`
|
||||
- `POST /time/start {workspace_id, book_id?, page_id?, description?}` (409 if running)
|
||||
- `POST /time/stop {workspace_id}` → sets `ended_at` + `duration_seconds`
|
||||
- `PATCH /time/:id`, `DELETE /time/:id` (own or admin+)
|
||||
|
||||
## Admin (requires `is_admin`)
|
||||
|
||||
- `GET /admin/overview` → counts + recent users
|
||||
- `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 /workspaces/:id/permissions`, `PATCH /workspaces/:id/permissions`
|
||||
(per-user allow/deny/inherit overrides, workspace admin+)
|
||||
- `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,attachment?}`,
|
||||
`PATCH /chat/message/:id`, `DELETE /chat/message/:id`, `GET /chat/:workspaceId/search?q=`
|
||||
- `GET /dm`, `POST /dm {user_id}`, `GET /dm/:id/messages`, `POST /dm/:id/messages`,
|
||||
`PATCH/DELETE /dm/message/:mid`, `GET /dm/:id/search?q=`
|
||||
- `POST /reactions/toggle {target_type,target_id,emoji}` (chat + DM messages)
|
||||
- Socket: `chat:join`, `dm:join`, `chat:typing`, `dm:typing`, `presence:online`,
|
||||
`chat:message/edited/deleted`, `dm:message/edited/deleted`, `reaction:updated`
|
||||
- `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
|
||||
|
||||
|
||||
Generated
+86
@@ -9,6 +9,12 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/extension-table": "^2.4.0",
|
||||
"@tiptap/extension-table-cell": "^2.4.0",
|
||||
"@tiptap/extension-table-header": "^2.4.0",
|
||||
"@tiptap/extension-table-row": "^2.4.0",
|
||||
"@tiptap/extension-task-item": "^2.4.0",
|
||||
"@tiptap/extension-task-list": "^2.4.0",
|
||||
"@tiptap/pm": "^2.4.0",
|
||||
"@tiptap/starter-kit": "^2.4.0",
|
||||
"@tiptap/vue-3": "^2.4.0",
|
||||
@@ -1128,6 +1134,86 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "2.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.3.tgz",
|
||||
"integrity": "sha512-xBDaT/ixkmrHKuCUlJNbzRolxjf0fFkHxybyeqfcGNDbVwOb6pOGZly6mGjBG1TXrx3U37y0jmFpwW6WU1zokA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-cell": {
|
||||
"version": "2.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.3.tgz",
|
||||
"integrity": "sha512-f697x9RDiva8EFZvLBxEp6Nx6sY5KRbHdNMhbCnqC1PrkRKNCTOib9ddWpqIERzs3HhmqZWL7XCPzvE/t8C+IQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-header": {
|
||||
"version": "2.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.3.tgz",
|
||||
"integrity": "sha512-x//GuYJlTzM3GavsDxBRDjGHMY9bbnmglQ0EAPVwQM5QHXpjwKzZN7wvDMM/RwxmxzYWd6Z+Fp7Zk8RYckpzhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-row": {
|
||||
"version": "2.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.3.tgz",
|
||||
"integrity": "sha512-0ekXiw6aAESlcrKFmI90ar/j7XJmfdxeJEDeGZuMwOAQs+qq5RAB7ug5WPCZjG0Kqa+Kyxzb1jMw9pSkuTYp+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-task-item": {
|
||||
"version": "2.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.3.tgz",
|
||||
"integrity": "sha512-7ES+qMoMgjb2iXI8iRzgegMeM1pE4zQAekT/5lQyuTv6oEyZw5s3TUZWeTYCq5TI/MezYGu3X2sV5PQnfBAsdA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-task-list": {
|
||||
"version": "2.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.3.tgz",
|
||||
"integrity": "sha512-T8Y3S95ZzYlh82KGd0H81QOsJZVWweUVps6l7wkiOReNsJxhkb4qW/smNE0TeNhvbU307emiPL0CH5e+eHxjEg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "2.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.3.tgz",
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/extension-table": "^2.4.0",
|
||||
"@tiptap/extension-table-cell": "^2.4.0",
|
||||
"@tiptap/extension-table-header": "^2.4.0",
|
||||
"@tiptap/extension-table-row": "^2.4.0",
|
||||
"@tiptap/extension-task-item": "^2.4.0",
|
||||
"@tiptap/extension-task-list": "^2.4.0",
|
||||
"@tiptap/pm": "^2.4.0",
|
||||
"@tiptap/starter-kit": "^2.4.0",
|
||||
"@tiptap/vue-3": "^2.4.0",
|
||||
|
||||
+41
-6
@@ -1,17 +1,52 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useThemeStore } from './stores/theme';
|
||||
import ThemeToggle from './components/ThemeToggle.vue';
|
||||
import { useLocaleStore } from './stores/locale';
|
||||
import { useUiStore } from './stores/ui';
|
||||
import { fileUrl } from './files';
|
||||
import { connectSocket } from './socket';
|
||||
import SettingsWindow from './components/SettingsWindow.vue';
|
||||
import TimeTrackerButton from './components/TimeTrackerButton.vue';
|
||||
import Bell from './components/Bell.vue';
|
||||
import Icon from './components/Icon.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
onMounted(() => theme.apply());
|
||||
const locale = useLocaleStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const bgRaw = computed(() => auth.user?.bgImage || '');
|
||||
const bgIsImage = computed(() => /^(https?:\/\/|\/uploads|data:)/.test(bgRaw.value));
|
||||
const bgStyle = computed(() => {
|
||||
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);
|
||||
theme.apply();
|
||||
auth.applyPrefs();
|
||||
if (auth.isLoggedIn) connectSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="position: fixed; top: 12px; right: 12px; z-index: 50">
|
||||
<ThemeToggle />
|
||||
<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="Settings"><Icon name="settings" /></button>
|
||||
</div>
|
||||
<router-view />
|
||||
<SettingsWindow />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+334
-60
@@ -1,93 +1,367 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-soft: #f7f7f5;
|
||||
--sidebar: #fbfbfa;
|
||||
--text: #111111;
|
||||
--muted: #6b6b6b;
|
||||
--border: #e6e6e2;
|
||||
--accent: #2383e2;
|
||||
--accent-soft: #e8f3fd;
|
||||
--card: #ffffff;
|
||||
--input: #ffffff;
|
||||
--bg: #f6f4ef;
|
||||
--bg-soft: #efece4;
|
||||
--sidebar: linear-gradient(180deg, #fdfcf9 0%, #f3f0e8 100%);
|
||||
--text: #1c1a15;
|
||||
--muted: #8a8474;
|
||||
--border: #e3ddcf;
|
||||
--accent: #7c5cf0;
|
||||
--accent-2: #00c2a8;
|
||||
--accent-soft: #ece5fd;
|
||||
--card: #fffdf8;
|
||||
--input: #fffdf8;
|
||||
--shadow: 0 6px 24px rgba(80, 60, 140, 0.1);
|
||||
--radius: 14px;
|
||||
}
|
||||
[data-theme='dark'] {
|
||||
--bg: #191919;
|
||||
--bg-soft: #202020;
|
||||
--sidebar: #202020;
|
||||
--text: #e8e8e8;
|
||||
--muted: #9b9b9b;
|
||||
--border: #2e2e2e;
|
||||
--accent: #4fa3e3;
|
||||
--accent-soft: #1e3a52;
|
||||
--card: #202020;
|
||||
--input: #262626;
|
||||
--bg: #14131a;
|
||||
--bg-soft: #1d1c26;
|
||||
--sidebar: linear-gradient(180deg, #1d1c26 0%, #16151d 100%);
|
||||
--text: #f0ede6;
|
||||
--muted: #9a94a8;
|
||||
--border: #2d2b3a;
|
||||
--accent: #a78bfa;
|
||||
--accent-2: #2dd4bf;
|
||||
--accent-soft: #2c2547;
|
||||
--card: #1e1d28;
|
||||
--input: #262534;
|
||||
--shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
transition: background 0.25s, color 0.25s;
|
||||
}
|
||||
.bg-layer {
|
||||
position: fixed; inset: -20px; z-index: -1;
|
||||
background-size: cover; background-position: center;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
h2, h3, h4 { letter-spacing: 0.2px; }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: #fff; border: none; border-radius: 10px;
|
||||
padding: 8px 14px; cursor: pointer; font-size: 14px; font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text);
|
||||
border: 1px solid var(--border); box-shadow: none;
|
||||
}
|
||||
button.mini { padding: 2px 8px; font-size: 13px; border-radius: 8px; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.icon-btn { font-size: 18px; padding: 4px 8px; }
|
||||
input, select, textarea {
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
background: var(--input); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 8px 10px; font-size: 14px; width: 100%;
|
||||
}
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--sidebar);
|
||||
width: 292px; min-width: 292px; background: var(--sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
||||
max-height: 100vh; overflow-y: auto; position: sticky; top: 0;
|
||||
}
|
||||
.main { flex: 1; padding: 24px; max-width: 960px; }
|
||||
.main { flex: 1; padding: 28px; max-width: 1060px; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow);
|
||||
}
|
||||
.auth-wrap { max-width: 380px; margin: 10vh auto; }
|
||||
.auth-wrap { max-width: 400px; margin: 10vh auto; }
|
||||
.auth-wrap h1 { margin-bottom: 4px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.tree-item { padding: 4px 8px; border-radius: 6px; cursor: pointer; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 10px; }
|
||||
.topbar.wrap { flex-wrap: wrap; }
|
||||
.top-right { position: fixed; top: 12px; right: 12px; z-index: 60; display: flex; gap: 8px; align-items: flex-start; }
|
||||
|
||||
/* Greeting */
|
||||
.greeting {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
background: linear-gradient(135deg, var(--accent-soft), transparent);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 16px 20px; margin-bottom: 16px; box-shadow: var(--shadow);
|
||||
}
|
||||
.clock {
|
||||
font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
padding: 6px 14px; border-radius: 12px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Sidebar bits */
|
||||
.user-chip {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 8px 10px;
|
||||
}
|
||||
.side-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
.side-nav a {
|
||||
text-decoration: none; color: var(--text); font-weight: 600;
|
||||
padding: 7px 10px; border-radius: 10px;
|
||||
}
|
||||
.side-nav a:hover { background: var(--bg-soft); }
|
||||
.side-nav a.router-link-exact-active { background: var(--accent-soft); }
|
||||
.side-label { font-size: 12px; font-weight: 800; text-transform: uppercase; color: var(--muted); margin-top: 6px; }
|
||||
.book { margin-bottom: 2px; }
|
||||
.book-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; }
|
||||
.book-pages { margin-left: 8px; border-left: 2px solid var(--border); padding-left: 4px; }
|
||||
.side-footer { margin-top: auto; display: flex; justify-content: space-between; align-items: center; padding-top: 10px; }
|
||||
.tree-item {
|
||||
padding: 5px 8px; border-radius: 8px; cursor: pointer;
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 4px;
|
||||
}
|
||||
.tree-item:hover { background: var(--bg-soft); }
|
||||
.tree-item.active { background: var(--accent-soft); }
|
||||
.presence { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--accent); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700;
|
||||
.search-results { margin-top: -4px; }
|
||||
.pill {
|
||||
background: var(--accent-soft); border: 1px solid var(--border);
|
||||
border-radius: 20px; padding: 2px 10px; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.tiptap { min-height: 300px; border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
||||
.member-row { padding: 6px 0; border-top: 1px solid var(--border); }
|
||||
.presence { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.avatar {
|
||||
border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: #fff; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.link { color: var(--accent); cursor: pointer; }
|
||||
|
||||
/* Settings window 🪟 */
|
||||
.settings-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
}
|
||||
.settings-window {
|
||||
width: 640px; max-width: 100%; max-height: 88vh; overflow: hidden;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 18px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.settings-titlebar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.settings-titlebar strong { flex: 1; }
|
||||
.traffic { display: flex; gap: 6px; }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; cursor: pointer; }
|
||||
.dot.red { background: #ff5f57; } .dot.yellow { background: #febc2e; } .dot.green { background: #28c840; }
|
||||
.settings-body { display: flex; min-height: 0; }
|
||||
.settings-tabs {
|
||||
display: flex; flex-direction: column; gap: 4px; min-width: 150px;
|
||||
padding: 14px 10px; border-right: 1px solid var(--border);
|
||||
}
|
||||
.settings-tabs button { background: transparent; color: var(--text); box-shadow: none; text-align: left; }
|
||||
.settings-tabs button.active { background: var(--accent-soft); }
|
||||
.settings-content { flex: 1; padding: 18px; overflow-y: auto; }
|
||||
.settings-content label { font-size: 13px; font-weight: 700; }
|
||||
.emoji-row { display: flex; gap: 4px; flex-wrap: wrap; font-size: 22px; }
|
||||
.emoji-row span { cursor: pointer; padding: 2px; border-radius: 8px; }
|
||||
.emoji-row span:hover { background: var(--accent-soft); }
|
||||
.preset-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
|
||||
.preset {
|
||||
height: 52px; border-radius: 10px; cursor: pointer;
|
||||
border: 2px solid var(--border); background: var(--bg-soft);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
||||
}
|
||||
.preset.active { border-color: var(--accent); }
|
||||
|
||||
/* Discovery */
|
||||
.discover { position: relative; }
|
||||
.discover-results {
|
||||
position: absolute; z-index: 20; width: 100%; margin-top: 4px;
|
||||
max-height: 220px; overflow-y: auto; padding: 6px;
|
||||
}
|
||||
.discover-row { display: flex; gap: 8px; align-items: center; padding: 6px; border-radius: 8px; cursor: pointer; }
|
||||
.discover-row:hover { background: var(--accent-soft); }
|
||||
|
||||
/* Tasks kanban */
|
||||
.new-task { display: grid; gap: 8px; margin-bottom: 14px; }
|
||||
.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; align-items: start; }
|
||||
.kanban-col { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; }
|
||||
.kanban-col h3 { margin: 4px 4px 10px; }
|
||||
.task-card { display: grid; gap: 6px; margin-bottom: 8px; }
|
||||
.task-card.overdue { border-color: #ef4444; }
|
||||
.overdue-tag { color: #ef4444; font-weight: 800; }
|
||||
|
||||
/* Calendar */
|
||||
.calendar {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px;
|
||||
}
|
||||
.cal-head { font-weight: 800; text-align: center; color: var(--muted); padding: 6px; }
|
||||
.cal-cell {
|
||||
min-height: 96px; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 6px; overflow: hidden;
|
||||
}
|
||||
.cal-cell.today { border: 2px solid var(--accent); }
|
||||
.cal-day { font-weight: 800; font-size: 13px; color: var(--muted); }
|
||||
.cal-task {
|
||||
font-size: 12px; background: var(--accent-soft); border-radius: 6px;
|
||||
padding: 2px 4px; margin-top: 3px; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.cal-task.done { opacity: 0.55; text-decoration: line-through; }
|
||||
|
||||
/* Tables + stats */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.data-table th { text-align: left; color: var(--muted); font-size: 12px; text-transform: uppercase; padding: 8px; }
|
||||
.data-table td { padding: 8px; border-top: 1px solid var(--border); }
|
||||
.data-table tr.running td { background: var(--accent-soft); }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
||||
.stat { font-size: 26px; display: grid; gap: 2px; text-align: center; }
|
||||
.stat strong { font-size: 30px; }
|
||||
.running-banner { border: 2px solid #ef4444; margin-bottom: 12px; }
|
||||
|
||||
/* Timer */
|
||||
.timetracker { position: relative; }
|
||||
.timer-btn.running { background: linear-gradient(135deg, #ef4444, #f59e0b); font-variant-numeric: tabular-nums; }
|
||||
.timer-pop {
|
||||
position: absolute; right: 0; top: 44px; width: 280px; z-index: 70;
|
||||
display: grid; gap: 8px;
|
||||
}
|
||||
|
||||
/* Editor */
|
||||
.tiptap { min-height: 300px; border: 1px solid var(--border); border-radius: 12px; padding: 14px; background: var(--card); }
|
||||
.tiptap:focus { outline: 2px solid var(--accent); }
|
||||
.tiptap table { border-collapse: collapse; width: 100%; margin: 8px 0; }
|
||||
.tiptap th, .tiptap td { border: 1px solid var(--border); padding: 6px 10px; }
|
||||
.tiptap th { background: var(--accent-soft); }
|
||||
.tiptap ul[data-type='taskList'] { list-style: none; padding-left: 4px; }
|
||||
.tiptap ul[data-type='taskList'] li { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.toolbar { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.toolbar button { padding: 4px 8px; font-size: 13px; }
|
||||
.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; }
|
||||
|
||||
/* ---- Chat: Discord/Slack mix ---- */
|
||||
.chat-layout { display: flex; flex-direction: column; }
|
||||
.chat-grid { display: grid; grid-template-columns: 280px 1fr; gap: 12px; align-items: start; }
|
||||
.chat-list { display: flex; flex-direction: column; gap: 4px; max-height: calc(100vh - 220px); overflow-y: auto; }
|
||||
.conv-row {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
padding: 8px; border-radius: 12px; cursor: pointer;
|
||||
}
|
||||
.conv-row:hover { background: var(--bg-soft); }
|
||||
.conv-row.active { background: var(--accent-soft); }
|
||||
.avatar-wrap { position: relative; flex-shrink: 0; }
|
||||
.online-dot {
|
||||
position: absolute; bottom: -1px; right: -1px;
|
||||
width: 11px; height: 11px; border-radius: 50%;
|
||||
background: #22c55e; border: 2px solid var(--card);
|
||||
}
|
||||
.badge.static { position: static; }
|
||||
.chat-pane { min-width: 0; display: flex; flex-direction: column; }
|
||||
.chat-box.slim { max-height: calc(100vh - 340px); min-height: 40vh; }
|
||||
.typing { font-style: italic; margin: 4px 0; min-height: 18px; }
|
||||
.search-hits { margin-top: 8px; max-height: 140px; overflow-y: auto; }
|
||||
.reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.reaction {
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 1px 8px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.reaction.mine { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.msg-actions { display: flex; gap: 4px; margin-top: 4px; opacity: 0.35; position: relative; }
|
||||
.chat-bubble:hover .msg-actions { opacity: 1; }
|
||||
.emoji-pop {
|
||||
position: absolute; z-index: 30; background: var(--card);
|
||||
border: 1px solid var(--border); border-radius: 12px; padding: 6px;
|
||||
display: flex; gap: 2px; font-size: 20px; box-shadow: var(--shadow);
|
||||
}
|
||||
.emoji-pop span { cursor: pointer; padding: 2px; border-radius: 6px; }
|
||||
.emoji-pop span:hover { background: var(--accent-soft); }
|
||||
.chat-img { max-width: 320px; border-radius: 10px; margin-top: 6px; display: block; }
|
||||
.attach { cursor: pointer; }
|
||||
|
||||
/* ---- 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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { fileUrl } from '../files';
|
||||
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
|
||||
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="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>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<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';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
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')">
|
||||
<Icon name="bell" /><span v-if="total" class="badge">{{ total }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="card bell-pop">
|
||||
<h4 style="margin: 0 0 8px"><Icon name="bell" :size="16" /> {{ 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>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
import { fileUrl } from '../files';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Avatar from './Avatar.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: Object,
|
||||
targetType: { type: String, default: 'chat' }, // 'chat' | 'dm'
|
||||
basePath: { type: String, default: 'chat' }, // REST base for edit/delete
|
||||
});
|
||||
const emit = defineEmits(['deleted']);
|
||||
const auth = useAuthStore();
|
||||
const L = useLocaleStore();
|
||||
|
||||
const editing = ref(false);
|
||||
const editText = ref('');
|
||||
const showPicker = ref(false);
|
||||
const FLAT = ['👍', '❤️', '😄', '🎉', '👀', '✅', '🔥', '💡'];
|
||||
|
||||
const mine = (id) => (props.message.reactions || []).some((r) => (r.users || []).includes(id));
|
||||
|
||||
async function toggle(emoji) {
|
||||
showPicker.value = false;
|
||||
const { data } = await api.post('/reactions/toggle', {
|
||||
target_type: props.targetType, target_id: props.message.id, emoji,
|
||||
});
|
||||
props.message.reactions = data.reactions;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editText.value = props.message.body;
|
||||
editing.value = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editText.value.trim()) return;
|
||||
const { data } = await api.patch(`/${props.basePath}/message/${props.message.id}`, {
|
||||
body: editText.value.trim(),
|
||||
});
|
||||
Object.assign(props.message, data.message);
|
||||
editing.value = false;
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('✕?')) return;
|
||||
await api.delete(`/${props.basePath}/message/${props.message.id}`);
|
||||
emit('deleted', props.message.id);
|
||||
}
|
||||
|
||||
function attachSrc() {
|
||||
return props.message.attachment ? fileUrl(props.message.attachment) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-msg" :class="{ mine: message.user_id === auth.user?.id }">
|
||||
<Avatar :name="message.author_name" :avatar="message.author_avatar" :size="32" />
|
||||
<div class="chat-bubble">
|
||||
<div class="muted chat-meta">
|
||||
<strong>{{ message.author_name || '?' }}</strong> ·
|
||||
{{ new Date(message.created_at).toLocaleString() }}
|
||||
<span v-if="message.edited_at" class="muted"> · {{ L.t('chat.edited') }}</span>
|
||||
</div>
|
||||
<div v-if="!editing">{{ message.body }}</div>
|
||||
<div v-else class="row">
|
||||
<input v-model="editText" @keyup.enter="saveEdit" />
|
||||
<button class="mini" @click="saveEdit">✓</button>
|
||||
<button class="ghost mini" @click="editing = false">✕</button>
|
||||
</div>
|
||||
<img v-if="attachSrc()" :src="attachSrc()" class="chat-img" loading="lazy" />
|
||||
<div v-if="(message.reactions || []).length" class="reactions">
|
||||
<span
|
||||
v-for="r in message.reactions" :key="r.emoji"
|
||||
class="reaction" :class="{ mine: mine(auth.user?.id) }"
|
||||
@click="toggle(r.emoji)"
|
||||
>
|
||||
{{ r.emoji }} {{ r.count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="msg-actions">
|
||||
<button class="ghost mini" @click="showPicker = !showPicker"><Icon name="smile" :size="14" /></button>
|
||||
<div v-if="showPicker" class="emoji-pop">
|
||||
<span v-for="e in FLAT" :key="e" @click="toggle(e)">{{ e }}</span>
|
||||
</div>
|
||||
<button v-if="message.user_id === auth.user?.id" class="ghost mini" @click="startEdit">{{ L.t('chat.edit') }}</button>
|
||||
<button v-if="message.user_id === auth.user?.id" class="ghost mini" @click="remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
|
||||
const props = defineProps({ name: String });
|
||||
const L = useLocaleStore();
|
||||
const now = ref(new Date());
|
||||
let timer = null;
|
||||
|
||||
onMounted(() => { timer = setInterval(() => (now.value = new Date()), 1000); });
|
||||
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: '🌆' };
|
||||
return { key: 'greet.night', emoji: '🌙' };
|
||||
});
|
||||
|
||||
const clock = computed(() =>
|
||||
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
||||
);
|
||||
const date = computed(() =>
|
||||
now.value.toLocaleDateString(
|
||||
L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US',
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' },
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greeting">
|
||||
<div>
|
||||
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
|
||||
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
|
||||
</div>
|
||||
<div class="clock">{{ clock }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
// Flat, modern line icons (inline SVG, no external dependency).
|
||||
// Usage: <Icon name="home" :size="18" />
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: Number, default: 18 },
|
||||
});
|
||||
|
||||
const PATHS = {
|
||||
home: '<path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/>',
|
||||
tasks: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 12l2 2 4-4"/>',
|
||||
calendar: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||
timer: '<circle cx="12" cy="13" r="7"/><path d="M12 9v4l2.5 2.5M9 2h6"/>',
|
||||
chat: '<path d="M4 5h16v11H9l-5 4z"/>',
|
||||
shield: '<path d="M12 3l7 3v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6z"/><path d="M9.5 12l2 2 3.5-4"/>',
|
||||
settings:
|
||||
'<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/><path d="M4 12h9M17 12h3"/><circle cx="15" cy="12" r="2"/>',
|
||||
bell: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 8-3 8h18s-3-1-3-8"/><path d="M10.3 21a2 2 0 0 0 3.4 0"/>',
|
||||
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
||||
check: '<path d="M4 12l5 5L20 7"/>',
|
||||
chevDown: '<path d="M6 9l6 6 6-6"/>',
|
||||
chevRight: '<path d="M9 6l6 6-6 6"/>',
|
||||
users: '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 20c0-3.6 2.5-6 5.5-6s5.5 2.4 5.5 6"/><circle cx="17" cy="9" r="2.5"/><path d="M15.8 14.6c2.7.4 4.7 2.4 4.7 5.4"/>',
|
||||
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.5-7 8-7s8 3 8 7"/>',
|
||||
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3 2"/>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
||||
image: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M21 16l-5-5-9 9"/>',
|
||||
attach: '<path d="M21 11l-9 9a5.5 5.5 0 0 1-8-8l9-9a4 4 0 0 1 5 5l-9 9a2.5 2.5 0 0 1-4-4l8-8"/>',
|
||||
send: '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>',
|
||||
edit: '<path d="M17 3l4 4L8 20l-5 1 1-5z"/>',
|
||||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 5l2 2M14 8l2 2"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
||||
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
||||
desktop: '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||
lock: '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
back: '<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>',
|
||||
smile: '<circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>',
|
||||
dot: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
|
||||
};
|
||||
|
||||
const inner = computed(() => PATHS[props.name] || '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size" :height="size" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="flex-shrink: 0; vertical-align: -3px" v-html="inner"
|
||||
></svg>
|
||||
</template>
|
||||
@@ -1,21 +1,34 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import UserSearch from './UserSearch.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const props = defineProps({ workspaceId: String });
|
||||
const emit = defineEmits(['invited']);
|
||||
const L = useLocaleStore();
|
||||
const email = ref('');
|
||||
const picked = ref(null);
|
||||
const role = ref('member');
|
||||
const msg = ref('');
|
||||
const err = ref('');
|
||||
|
||||
function onPick(u) {
|
||||
picked.value = u;
|
||||
email.value = u.email;
|
||||
}
|
||||
|
||||
async function invite() {
|
||||
msg.value = '';
|
||||
err.value = '';
|
||||
const target = email.value.trim();
|
||||
if (!target) return;
|
||||
try {
|
||||
await api.post(`/workspaces/${props.workspaceId}/members`, { email: email.value, role: role.value });
|
||||
msg.value = `Invited ${email.value} as ${role.value}`;
|
||||
await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
|
||||
msg.value = `✨ ${target} → ${role.value}`;
|
||||
email.value = '';
|
||||
picked.value = null;
|
||||
emit('invited');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Invite failed';
|
||||
@@ -25,17 +38,19 @@ async function invite() {
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h4>Invite collaborator</h4>
|
||||
<p class="muted">They must register first with the same email.</p>
|
||||
<div class="row">
|
||||
<h4><Icon name="mail" :size="16" /> {{ L.t('inv.title') }}</h4>
|
||||
<p class="muted">{{ L.t('inv.hint') }}</p>
|
||||
<UserSearch @pick="onPick" />
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<input v-model="email" placeholder="teammate@example.com" />
|
||||
<select v-model="role" style="max-width: 130px">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button @click="invite">Invite</button>
|
||||
<button @click="invite">{{ L.t('inv.btn') }}</button>
|
||||
</div>
|
||||
<p v-if="picked" class="muted">🔎 {{ picked.name }} <{{ picked.email }}></p>
|
||||
<p v-if="msg" style="color: green">{{ msg }}</p>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<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';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import Avatar from './Avatar.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const L = useLocaleStore();
|
||||
const theme = useThemeStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const form = reactive({ name: '', email: '', avatar: '' });
|
||||
const curPw = ref('');
|
||||
const newPw = ref('');
|
||||
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: '',
|
||||
sunset: 'linear-gradient(135deg,#ff9a56,#ff5e78 55%,#a855f7)',
|
||||
ocean: 'linear-gradient(135deg,#0ea5e9,#6366f1 55%,#14b8a6)',
|
||||
forest: 'linear-gradient(135deg,#166534,#4d7c0f 60%,#a3a380)',
|
||||
midnight: 'linear-gradient(135deg,#0f172a,#4c1d95 60%,#0ea5e9)',
|
||||
};
|
||||
const EMOJIS = ['🦊', '🐼', '🐸', '🚀', '🌙', '🔥', '🍩', '🎧', '🌵', '⚡'];
|
||||
|
||||
watch(
|
||||
() => ui.settingsOpen,
|
||||
(open) => {
|
||||
if (open && auth.user) {
|
||||
form.name = auth.user.name || '';
|
||||
form.email = auth.user.email || '';
|
||||
form.avatar = auth.user.avatar || '';
|
||||
bgUrl.value = auth.user.bgImage?.startsWith('http') ? auth.user.bgImage : '';
|
||||
bgPreset.value = Object.entries(GRADIENTS).find(
|
||||
([, 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 = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function bgValue() {
|
||||
if (bgPreset.value === 'custom') return bgUrl.value.trim();
|
||||
return GRADIENTS[bgPreset.value] || '';
|
||||
}
|
||||
|
||||
function persistUser(user) {
|
||||
auth.user = user;
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
auth.applyPrefs();
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
msg.value = '';
|
||||
err.value = '';
|
||||
try {
|
||||
const { data } = await api.patch('/users/me', {
|
||||
name: form.name.trim(), email: form.email.trim(), avatar: form.avatar.trim(),
|
||||
});
|
||||
persistUser(data.user);
|
||||
msg.value = L.t('set.saved');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
async function changePw() {
|
||||
msg.value = '';
|
||||
err.value = '';
|
||||
try {
|
||||
await api.patch('/users/me/password', { currentPassword: curPw.value, newPassword: newPw.value });
|
||||
curPw.value = '';
|
||||
newPw.value = '';
|
||||
msg.value = L.t('set.pwChanged');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
async function setTheme(mode) {
|
||||
theme.set(mode);
|
||||
try {
|
||||
const { data } = await api.patch('/users/me', { theme: mode });
|
||||
persistUser(data.user);
|
||||
} catch { /* offline-friendly: theme still applies locally */ }
|
||||
}
|
||||
|
||||
async function setLocale(l) {
|
||||
L.set(l);
|
||||
try {
|
||||
const { data } = await api.patch('/users/me', { locale: l });
|
||||
persistUser(data.user);
|
||||
} catch { /* keep local */ }
|
||||
}
|
||||
|
||||
async function saveBackground() {
|
||||
msg.value = '';
|
||||
err.value = '';
|
||||
try {
|
||||
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';
|
||||
err.value = '';
|
||||
try {
|
||||
form.avatar = await uploadImage(file);
|
||||
const { data } = await api.patch('/users/me', { avatar: form.avatar });
|
||||
persistUser(data.user);
|
||||
msg.value = L.t('set.saved');
|
||||
} catch {
|
||||
err.value = 'Upload failed';
|
||||
} finally {
|
||||
uploading.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBg(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploading.value = 'bg';
|
||||
err.value = '';
|
||||
try {
|
||||
bgUrl.value = await uploadImage(file);
|
||||
bgPreset.value = 'custom';
|
||||
const { data } = await api.patch('/users/me', { bg_image: bgUrl.value });
|
||||
persistUser(data.user);
|
||||
msg.value = L.t('set.saved');
|
||||
} catch {
|
||||
err.value = 'Upload failed';
|
||||
} finally {
|
||||
uploading.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="ui.settingsOpen" class="settings-overlay" @click.self="ui.closeSettings()">
|
||||
<div class="settings-window">
|
||||
<div class="settings-titlebar">
|
||||
<div class="traffic">
|
||||
<span class="dot red" @click="ui.closeSettings()"></span>
|
||||
<span class="dot yellow"></span>
|
||||
<span class="dot green"></span>
|
||||
</div>
|
||||
<strong><Icon name="settings" :size="18" /> {{ L.t('set.title') }}</strong>
|
||||
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<nav class="settings-tabs">
|
||||
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'"><Icon name="user" :size="16" /> {{ L.t('set.profile') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'appearance' }" @click="ui.settingsTab = 'appearance'"><Icon name="sun" :size="16" /> {{ L.t('set.appearance') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'language' }" @click="ui.settingsTab = 'language'"><Icon name="globe" :size="16" /> {{ L.t('set.language') }}</button>
|
||||
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'"><Icon name="image" :size="16" /> {{ L.t('set.background') }}</button>
|
||||
</nav>
|
||||
<section class="settings-content">
|
||||
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
|
||||
<div class="row">
|
||||
<Avatar :name="form.name" :avatar="form.avatar" :size="48" />
|
||||
<div>
|
||||
<label>{{ L.t('set.avatar') }}</label>
|
||||
<input v-model="form.avatar" :placeholder="L.t('set.avatarHint')" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p class="muted">✓ {{ L.t('set.autoSaved') }}</p>
|
||||
<label>{{ L.t('set.name') }}</label>
|
||||
<input v-model="form.name" />
|
||||
<label>{{ L.t('set.email') }}</label>
|
||||
<input v-model="form.email" type="email" />
|
||||
<button @click="saveProfile">{{ L.t('set.save') }}</button>
|
||||
<hr />
|
||||
<label>{{ L.t('set.curPw') }}</label>
|
||||
<input v-model="curPw" type="password" />
|
||||
<label>{{ L.t('set.newPw') }}</label>
|
||||
<input v-model="newPw" type="password" />
|
||||
<button class="ghost" @click="changePw">{{ L.t('set.changePw') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
|
||||
<label>{{ L.t('set.theme') }}</label>
|
||||
<div class="row">
|
||||
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')"><Icon name="sun" :size="16" /> {{ L.t('set.light') }}</button>
|
||||
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')"><Icon name="moon" :size="16" /> {{ L.t('set.dark') }}</button>
|
||||
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')"><Icon name="desktop" :size="16" /> {{ L.t('set.system') }}</button>
|
||||
</div>
|
||||
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
|
||||
</div>
|
||||
|
||||
<div v-if="ui.settingsTab === 'language'" style="display: grid; gap: 10px">
|
||||
<button :class="{ ghost: L.locale !== 'de' }" @click="setLocale('de')">🇩🇪 Deutsch</button>
|
||||
<button :class="{ ghost: L.locale !== 'en' }" @click="setLocale('en')">🇬🇧 English</button>
|
||||
<button :class="{ ghost: L.locale !== 'zh' }" @click="setLocale('zh')">🇨🇳 中文</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ui.settingsTab === 'background'" style="display: grid; gap: 10px">
|
||||
<div class="preset-grid">
|
||||
<div
|
||||
v-for="(grad, key) in GRADIENTS"
|
||||
:key="key"
|
||||
class="preset"
|
||||
:class="{ active: bgPreset === key }"
|
||||
:style="grad ? { background: grad } : {}"
|
||||
@click="bgPreset = key"
|
||||
>
|
||||
{{ key === 'none' ? '🚫' : '' }}
|
||||
</div>
|
||||
</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.autoSaved') }}</p>
|
||||
<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>
|
||||
|
||||
<p v-if="msg" style="color: green">{{ msg }}</p>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import { useUiStore } from '../stores/ui';
|
||||
import PageTree from './PageTree.vue';
|
||||
import Avatar from './Avatar.vue';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const props = defineProps({ activePageId: String });
|
||||
const auth = useAuthStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const L = useLocaleStore();
|
||||
const ui = useUiStore();
|
||||
const router = useRouter();
|
||||
|
||||
const newWs = ref('');
|
||||
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) {
|
||||
collapsed.value[id] = !collapsed.value[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() {
|
||||
try {
|
||||
await ws.fetchWorkspaces();
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function switchWs(id) {
|
||||
ws.setCurrent(id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function createWs() {
|
||||
if (!newWs.value.trim()) return;
|
||||
try {
|
||||
await ws.createWorkspace(newWs.value.trim());
|
||||
newWs.value = '';
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
await ws.createBook(newBook.value.trim(), '📚');
|
||||
newBook.value = '';
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBook(id) {
|
||||
if (!confirm('✕?')) return;
|
||||
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; }
|
||||
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() {
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="user-chip">
|
||||
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
|
||||
<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="Settings"><Icon name="settings" /></button>
|
||||
</div>
|
||||
|
||||
<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')"><Icon name="refresh" /></button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
|
||||
<button @click="createWs">+</button>
|
||||
</div>
|
||||
|
||||
<nav class="side-nav">
|
||||
<router-link to="/"><Icon name="home" /> {{ L.t('nav.dashboard') }}</router-link>
|
||||
<router-link to="/tasks"><Icon name="tasks" /> {{ L.t('nav.tasks') }}</router-link>
|
||||
<router-link to="/calendar"><Icon name="calendar" /> {{ L.t('nav.calendar') }}</router-link>
|
||||
<router-link to="/time"><Icon name="timer" /> {{ L.t('nav.time') }}</router-link>
|
||||
<router-link to="/chat"><Icon name="chat" /> {{ L.t('nav.chat') }}</router-link>
|
||||
<router-link v-if="auth.user?.isAdmin" to="/admin"><Icon name="shield" /> {{ L.t('nav.admin') }}</router-link>
|
||||
</nav>
|
||||
|
||||
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
|
||||
<div v-if="results.length" class="card search-results">
|
||||
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
|
||||
🔍 {{ r.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-label">{{ L.t('side.books') }}</div>
|
||||
<div class="row">
|
||||
<input v-model="newBook" :placeholder="L.t('side.newBookPh')" @keyup.enter="createBook" />
|
||||
<button @click="createBook">+</button>
|
||||
</div>
|
||||
<div v-for="b in ws.books" :key="b.id" class="book">
|
||||
<div class="tree-item book-head" @click="toggleBook(b.id)">
|
||||
<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, $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(null, null)" />
|
||||
<button @click="createPage(null, null)">+</button>
|
||||
</div>
|
||||
<PageTree
|
||||
:nodes="ws.tree()" :active-id="activePageId"
|
||||
@open="router.push(`/page/${$event}`)"
|
||||
@add-child="createPage(null, $event)"
|
||||
/>
|
||||
|
||||
<div class="side-footer">
|
||||
<span class="muted">{{ ws.role }}</span>
|
||||
<button class="ghost" @click="logout"><Icon name="logout" /> {{ L.t('nav.logout') }}</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from '../stores/theme';
|
||||
const theme = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<button class="ghost" @click="theme.set('light')" :disabled="theme.resolved === 'light' && theme.mode !== 'system'">☀ Light</button>
|
||||
<button class="ghost" @click="theme.set('dark')" :disabled="theme.resolved === 'dark' && theme.mode !== 'system'">🌙 Dark</button>
|
||||
<button class="ghost" @click="theme.toggle()">Toggle</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useTimeStore, formatDuration } from '../stores/time';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Icon from './Icon.vue';
|
||||
|
||||
const time = useTimeStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const L = useLocaleStore();
|
||||
const router = useRouter();
|
||||
const open = ref(false);
|
||||
const desc = ref('');
|
||||
const bookId = ref('');
|
||||
const tick = ref(0);
|
||||
let timer = null;
|
||||
|
||||
onMounted(() => { timer = setInterval(() => (tick.value += 1), 1000); });
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
|
||||
const elapsed = computed(() => {
|
||||
void tick.value;
|
||||
return time.open
|
||||
? formatDuration((Date.now() - new Date(time.open.started_at).getTime()) / 1000)
|
||||
: '00:00:00';
|
||||
});
|
||||
|
||||
async function toggle() {
|
||||
if (time.open) {
|
||||
await time.stop();
|
||||
open.value = false;
|
||||
} else {
|
||||
open.value = !open.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
await time.start(desc.value, bookId.value || null);
|
||||
desc.value = '';
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timetracker">
|
||||
<button
|
||||
class="ghost timer-btn"
|
||||
:class="{ running: !!time.open }"
|
||||
@click="toggle"
|
||||
:title="L.t('time.title')"
|
||||
>
|
||||
<Icon name="timer" :size="16" /> {{ time.open ? elapsed : L.t('time.start') }}
|
||||
</button>
|
||||
<div v-if="open && !time.open" class="card timer-pop">
|
||||
<input v-model="desc" :placeholder="L.t('time.descPh')" />
|
||||
<select v-model="bookId">
|
||||
<option value="">{{ L.t('time.book') }}: {{ L.t('time.none') }}</option>
|
||||
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
|
||||
</select>
|
||||
<div class="row">
|
||||
<button @click="start">{{ L.t('time.start') }}</button>
|
||||
<button class="ghost" @click="router.push('/time')">{{ L.t('time.entries') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import api from '../api';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Avatar from './Avatar.vue';
|
||||
|
||||
const props = defineProps({ workspaceId: String });
|
||||
const emit = defineEmits(['pick']);
|
||||
const L = useLocaleStore();
|
||||
const q = ref('');
|
||||
const results = ref([]);
|
||||
const busy = ref(false);
|
||||
let timer = null;
|
||||
|
||||
watch(q, () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(search, 250);
|
||||
});
|
||||
|
||||
async function search() {
|
||||
const query = q.value.trim();
|
||||
if (query.length < 2) { results.value = []; return; }
|
||||
busy.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/users/discover', { params: { q: query } });
|
||||
results.value = data.users;
|
||||
} catch {
|
||||
results.value = [];
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pick(u) {
|
||||
emit('pick', u);
|
||||
q.value = '';
|
||||
results.value = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discover">
|
||||
<input v-model="q" :placeholder="L.t('inv.ph')" />
|
||||
<div v-if="q.trim().length >= 2" class="discover-results card">
|
||||
<div v-if="busy" class="muted">{{ L.t('c.loading') }}</div>
|
||||
<div v-for="u in results" :key="u.id" class="discover-row" @click="pick(u)">
|
||||
<Avatar :name="u.name" :avatar="u.avatar" :size="24" />
|
||||
<span><strong>{{ u.name }}</strong><br /><span class="muted">{{ u.email }}</span></span>
|
||||
</div>
|
||||
<div v-if="!busy && !results.length" class="muted">∅</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export default {
|
||||
nav: {
|
||||
dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung',
|
||||
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', 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',
|
||||
evening: 'Guten Abend', night: 'Gute Nacht',
|
||||
},
|
||||
set: {
|
||||
title: 'Einstellungen', profile: 'Profil', appearance: 'Darstellung',
|
||||
language: 'Sprache', background: 'Hintergrund',
|
||||
name: 'Anzeigename', email: 'E-Mail', avatar: 'Profilbild (Emoji oder Bild-URL)',
|
||||
avatarHint: 'Z. B. 🦊 oder https://…/foto.png',
|
||||
curPw: 'Aktuelles Passwort', newPw: 'Neues Passwort (min. 8 Zeichen)',
|
||||
changePw: 'Passwort ändern', pwChanged: 'Passwort geändert ✅',
|
||||
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)', bgGlass: 'Widgets als Glas darstellen',
|
||||
upload: 'Bild hochladen …', autoSaved: 'Wird sofort übernommen ✓', 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',
|
||||
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',
|
||||
},
|
||||
cal: { title: 'Kalender', today: 'Heute' },
|
||||
time: {
|
||||
title: 'Zeiterfassung', start: '▶ Start', stop: '⏹ Stopp',
|
||||
descPh: 'Woran arbeitest du?', book: 'Book', none: '—',
|
||||
entries: 'Einträge', dur: 'Dauer', running: 'läuft …',
|
||||
total: 'Gesamt', noEntries: 'Noch keine Einträge. Starte oben den Timer! ⏱️',
|
||||
started: 'Start', ended: 'Ende',
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin-Dashboard', overview: '📊 Übersicht', users: '👥 Benutzer',
|
||||
name: 'Name', email: 'E-Mail', role: 'Rolle', admin: 'Admin', user: 'User',
|
||||
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',
|
||||
back: 'Zurück', loading: 'Lädt …',
|
||||
},
|
||||
inv: {
|
||||
title: 'Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.',
|
||||
ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen',
|
||||
pick: 'Person wählen …',
|
||||
},
|
||||
chat: {
|
||||
title: 'Team-Chat', ph: 'Nachricht schreiben …', send: 'Senden',
|
||||
empty: 'Noch keine Nachrichten. Sag Hallo! 👋',
|
||||
tabs: 'Chats', teamTabs: 'Team-Chats', search: 'Suchen …',
|
||||
type: 'Nachricht …', edit: 'Bearbeiten', edited: '(bearbeitet)',
|
||||
attach: 'Bild anhängen', typing: 'schreibt …', online: 'online',
|
||||
noConv: 'Wähle links einen Chat oder starte einen neuen.',
|
||||
pickUser: 'User suchen für neuen Chat …', unread: 'neu',
|
||||
},
|
||||
perm: {
|
||||
title: 'Rechte pro Benutzer', allow: 'Erlauben', deny: 'Verbieten',
|
||||
inherit: 'Vererbt', manage: 'Rechte',
|
||||
},
|
||||
com: { title: 'Kommentare', ph: 'Kommentar schreiben …', post: 'Posten' },
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
export default {
|
||||
nav: {
|
||||
dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking',
|
||||
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', 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',
|
||||
evening: 'Good evening', night: 'Good night',
|
||||
},
|
||||
set: {
|
||||
title: 'Settings', profile: 'Profile', appearance: 'Appearance',
|
||||
language: 'Language', background: 'Background',
|
||||
name: 'Display name', email: 'Email', avatar: 'Avatar (emoji or image URL)',
|
||||
avatarHint: 'E.g. 🦊 or https://…/photo.png',
|
||||
curPw: 'Current password', newPw: 'New password (min. 8 chars)',
|
||||
changePw: 'Change password', pwChanged: 'Password changed ✅',
|
||||
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', bgGlass: 'Glassmorphism widgets',
|
||||
upload: 'Upload image …', autoSaved: 'Applied instantly ✓', close: 'Close',
|
||||
presetNone: 'No image', presetCustom: 'Custom URL',
|
||||
},
|
||||
task: {
|
||||
title: 'Tasks', new: 'New task', ph: 'What needs doing?',
|
||||
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',
|
||||
},
|
||||
cal: { title: 'Calendar', today: 'Today' },
|
||||
time: {
|
||||
title: 'Time tracking', start: '▶ Start', stop: '⏹ Stop',
|
||||
descPh: 'What are you working on?', book: 'Book', none: '—',
|
||||
entries: 'Entries', dur: 'Duration', running: 'running …',
|
||||
total: 'Total', noEntries: 'No entries yet. Start the timer above! ⏱️',
|
||||
started: 'Started', ended: 'Ended',
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin dashboard', overview: '📊 Overview', users: '👥 Users',
|
||||
name: 'Name', email: 'Email', role: 'Role', admin: 'Admin', user: 'User',
|
||||
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',
|
||||
back: 'Back', loading: 'Loading …',
|
||||
},
|
||||
inv: {
|
||||
title: 'Invite collaborator', hint: 'Type to search – or enter a full email.',
|
||||
ph: 'Search name or email …', btn: 'Invite', done: 'Invited',
|
||||
pick: 'Pick a person …',
|
||||
},
|
||||
chat: {
|
||||
title: 'Team chat', ph: 'Write a message …', send: 'Send',
|
||||
empty: 'No messages yet. Say hi! 👋',
|
||||
tabs: 'Chats', teamTabs: 'Team chats', search: 'Search …',
|
||||
type: 'Message …', edit: 'Edit', edited: '(edited)',
|
||||
attach: 'Attach image', typing: 'is typing …', online: 'online',
|
||||
noConv: 'Pick a chat on the left or start a new one.',
|
||||
pickUser: 'Search user for new chat …', unread: 'new',
|
||||
},
|
||||
perm: {
|
||||
title: 'Per-user permissions', allow: 'Allow', deny: 'Deny',
|
||||
inherit: 'Inherited', manage: 'Rights',
|
||||
},
|
||||
com: { title: 'Comments', ph: 'Write a comment …', post: 'Post' },
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
export default {
|
||||
nav: {
|
||||
dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录',
|
||||
admin: '管理', logout: '退出登录', chat: '聊天',
|
||||
},
|
||||
side: {
|
||||
workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …',
|
||||
newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面',
|
||||
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: '下午好',
|
||||
evening: '晚上好', night: '晚安',
|
||||
},
|
||||
set: {
|
||||
title: '设置', profile: '个人资料', appearance: '外观',
|
||||
language: '语言', background: '背景',
|
||||
name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)',
|
||||
avatarHint: '例如 🦊 或 https://…/photo.png',
|
||||
curPw: '当前密码', newPw: '新密码(至少 8 位)',
|
||||
changePw: '修改密码', pwChanged: '密码已修改 ✅',
|
||||
save: '保存', saved: '已保存 ✅', theme: '配色方案',
|
||||
light: '浅色', dark: '深色', system: '跟随系统',
|
||||
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
|
||||
bgBlur: '模糊背景', bgGlass: '玻璃拟态组件',
|
||||
upload: '上传图片 …', autoSaved: '即时生效 ✓', close: '关闭',
|
||||
presetNone: '无背景', presetCustom: '自定义链接',
|
||||
},
|
||||
task: {
|
||||
title: '任务', new: '新建任务', ph: '要做什么?',
|
||||
todo: '📝 待办', doing: '⏳ 进行中', done: '✅ 已完成',
|
||||
priority: '优先级', low: '低', normal: '中', high: '高',
|
||||
due: '截止', noDue: '无截止日期', book: '书册', page: '页面',
|
||||
team: '团队', assignee: '负责人', allTeams: '全部团队',
|
||||
none: '—', overdue: '已逾期!', toTodo: '← 待办', toDoing: '进行中 →',
|
||||
toDone: '完成 →', reopen: '↩ 重新打开',
|
||||
},
|
||||
cal: { title: '日历', today: '今天' },
|
||||
time: {
|
||||
title: '时间记录', start: '▶ 开始', stop: '⏹ 停止',
|
||||
descPh: '在忙什么?', book: '书册', none: '—',
|
||||
entries: '记录', dur: '时长', running: '计时中 …',
|
||||
total: '总计', noEntries: '还没有记录,点击上方开始计时!⏱️',
|
||||
started: '开始', ended: '结束',
|
||||
},
|
||||
admin: {
|
||||
title: '管理面板', overview: '📊 总览', users: '👥 用户',
|
||||
name: '名称', email: '邮箱', role: '角色', admin: '管理员', user: '用户',
|
||||
workspaces: '工作区', joined: '加入时间', makeAdmin: '设为管理员',
|
||||
unmake: '取消管理员', del: '删除', confirm: '确认删除?',
|
||||
you: '(你)', books: '书册', pages: '页面', tasks: '任务', time: '时间记录',
|
||||
createUser: '创建用户', newPw: '密码', create: '创建', created: '已创建 ✅',
|
||||
},
|
||||
c: {
|
||||
save: '保存', cancel: '取消', delete: '删除', create: '创建',
|
||||
back: '返回', loading: '加载中 …',
|
||||
},
|
||||
inv: {
|
||||
title: '邀请协作者', hint: '输入搜索,或填写完整邮箱。',
|
||||
ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请',
|
||||
pick: '选择人员 …',
|
||||
},
|
||||
chat: {
|
||||
title: '团队聊天', ph: '写消息 …', send: '发送',
|
||||
empty: '还没有消息,打个招呼吧!👋',
|
||||
tabs: '私聊', teamTabs: '团队聊天', search: '搜索 …',
|
||||
type: '消息 …', edit: '编辑', edited: '(已编辑)',
|
||||
attach: '添加图片', typing: '正在输入 …', online: '在线',
|
||||
noConv: '在左侧选择聊天或开始新聊天。',
|
||||
pickUser: '搜索用户开始新聊天 …', unread: '新',
|
||||
},
|
||||
perm: {
|
||||
title: '用户权限', allow: '允许', deny: '拒绝',
|
||||
inherit: '继承', manage: '权限',
|
||||
},
|
||||
com: { title: '评论', ph: '写评论 …', post: '发送' },
|
||||
};
|
||||
@@ -3,10 +3,12 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { useThemeStore } from './stores/theme';
|
||||
import { useLocaleStore } from './stores/locale';
|
||||
import './assets/style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
useLocaleStore().set(localStorage.getItem('locale') || 'en');
|
||||
useThemeStore().apply();
|
||||
|
||||
@@ -2,17 +2,25 @@ 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 } },
|
||||
];
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes });
|
||||
@@ -25,6 +33,7 @@ router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.meta.auth && !auth.isLoggedIn) return '/login';
|
||||
if (to.meta.guest && auth.isLoggedIn) return '/';
|
||||
if (to.meta.admin && !auth.user?.isAdmin) return '/';
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import { usePresenceStore } from './stores/presence';
|
||||
|
||||
let socket = null;
|
||||
|
||||
@@ -9,6 +10,11 @@ export function connectSocket() {
|
||||
socket?.disconnect();
|
||||
const url = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';
|
||||
socket = io(url || undefined, { auth: { token } });
|
||||
socket.on('presence:online', (ids) => {
|
||||
try {
|
||||
usePresenceStore().online = ids || [];
|
||||
} catch { /* store not ready */ }
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import api from '../api';
|
||||
import { connectSocket, disconnectSocket } from '../socket';
|
||||
import { useLocaleStore } from './locale';
|
||||
import { useThemeStore } from './theme';
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
@@ -11,6 +13,11 @@ export const useAuthStore = defineStore('auth', {
|
||||
isLoggedIn: (s) => !!s.token,
|
||||
},
|
||||
actions: {
|
||||
applyPrefs() {
|
||||
if (!this.user) return;
|
||||
useLocaleStore().set(this.user.locale || 'en');
|
||||
useThemeStore().set(this.user.theme || 'system');
|
||||
},
|
||||
async register(email, name, password) {
|
||||
const { data } = await api.post('/auth/register', { email, name, password });
|
||||
this.apply(data);
|
||||
@@ -24,6 +31,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.token = data.token;
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
localStorage.setItem('token', data.token);
|
||||
this.applyPrefs();
|
||||
connectSocket();
|
||||
},
|
||||
async fetchMe() {
|
||||
@@ -31,6 +39,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
const { data } = await api.get('/auth/me');
|
||||
this.user = data.user;
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
this.applyPrefs();
|
||||
},
|
||||
logout() {
|
||||
this.user = null;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import de from '../i18n/de';
|
||||
import en from '../i18n/en';
|
||||
import zh from '../i18n/zh';
|
||||
|
||||
const dicts = { de, en, zh };
|
||||
|
||||
export const useLocaleStore = defineStore('locale', {
|
||||
state: () => ({
|
||||
locale: localStorage.getItem('locale') || 'en',
|
||||
}),
|
||||
actions: {
|
||||
set(l) {
|
||||
if (!dicts[l]) return;
|
||||
this.locale = l;
|
||||
localStorage.setItem('locale', l);
|
||||
document.documentElement.lang = l === 'zh' ? 'zh-CN' : l;
|
||||
},
|
||||
t(path) {
|
||||
const dict = dicts[this.locale] || en;
|
||||
const parts = String(path).split('.');
|
||||
let node = dict;
|
||||
for (const p of parts) {
|
||||
node = node?.[p];
|
||||
if (node === undefined) break;
|
||||
}
|
||||
if (typeof node === 'string') return node;
|
||||
// fallback to English, then to the key itself
|
||||
let fb = en;
|
||||
for (const p of parts) fb = fb?.[p];
|
||||
return typeof fb === 'string' ? fb : path;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const usePresenceStore = defineStore('presence', {
|
||||
state: () => ({
|
||||
online: [],
|
||||
}),
|
||||
actions: {
|
||||
isOnline(id) {
|
||||
return (this.online || []).includes(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import api from '../api';
|
||||
|
||||
export function formatDuration(totalSeconds) {
|
||||
const s = Math.max(0, Math.floor(totalSeconds || 0));
|
||||
const h = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||
const sec = String(s % 60).padStart(2, '0');
|
||||
return `${h}:${m}:${sec}`;
|
||||
}
|
||||
|
||||
export const useTimeStore = defineStore('time', {
|
||||
state: () => ({
|
||||
entries: [],
|
||||
open: null, // running timer entry (ended_at == null)
|
||||
workspaceId: null,
|
||||
}),
|
||||
getters: {
|
||||
elapsed: (s) => {
|
||||
if (!s.open) return 0;
|
||||
return (Date.now() - new Date(s.open.started_at).getTime()) / 1000;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async fetch(workspaceId) {
|
||||
this.workspaceId = workspaceId;
|
||||
const { data } = await api.get(`/time/by-workspace/${workspaceId}`);
|
||||
this.entries = data.entries;
|
||||
this.open = data.open;
|
||||
},
|
||||
async start(description = '', book_id = null) {
|
||||
const { data } = await api.post('/time/start', {
|
||||
workspace_id: this.workspaceId, description, book_id,
|
||||
});
|
||||
this.open = data.entry;
|
||||
this.entries.unshift(data.entry);
|
||||
return data.entry;
|
||||
},
|
||||
async stop() {
|
||||
const { data } = await api.post('/time/stop', { workspace_id: this.workspaceId });
|
||||
this.open = null;
|
||||
const idx = this.entries.findIndex((e) => e.id === data.entry.id);
|
||||
if (idx >= 0) this.entries[idx] = { ...data.entry, user_name: this.entries[idx].user_name };
|
||||
else this.entries.unshift(data.entry);
|
||||
return data.entry;
|
||||
},
|
||||
async remove(id) {
|
||||
await api.delete(`/time/${id}`);
|
||||
this.entries = this.entries.filter((e) => e.id !== id);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import api from '../api';
|
||||
|
||||
// Global UI state: settings window visibility.
|
||||
export const useUiStore = defineStore('ui', {
|
||||
state: () => ({
|
||||
settingsOpen: false,
|
||||
settingsTab: 'profile',
|
||||
}),
|
||||
actions: {
|
||||
openSettings(tab = 'profile') {
|
||||
this.settingsTab = tab;
|
||||
this.settingsOpen = true;
|
||||
},
|
||||
closeSettings() {
|
||||
this.settingsOpen = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
workspaces: [],
|
||||
currentId: localStorage.getItem('workspaceId') || null,
|
||||
pages: [],
|
||||
books: [],
|
||||
role: null,
|
||||
}),
|
||||
actions: {
|
||||
@@ -32,9 +33,28 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
this.role = data.role;
|
||||
return this.pages;
|
||||
},
|
||||
async fetchBooks() {
|
||||
if (!this.currentId) return [];
|
||||
const { data } = await api.get(`/books/by-workspace/${this.currentId}`);
|
||||
this.books = data.books;
|
||||
return this.books;
|
||||
},
|
||||
async createBook(title, icon) {
|
||||
const { data } = await api.post('/books', {
|
||||
workspace_id: this.currentId, title, icon,
|
||||
});
|
||||
this.books.push(data.book);
|
||||
return data.book;
|
||||
},
|
||||
// Tree of top-level pages (book_id == null), nested via parent_id.
|
||||
tree() {
|
||||
return this.treeFor(null);
|
||||
},
|
||||
// Tree of pages inside one book.
|
||||
treeFor(bookId) {
|
||||
const inScope = this.pages.filter((p) => (p.book_id || null) === (bookId || null));
|
||||
const byParent = new Map();
|
||||
for (const p of this.pages) {
|
||||
for (const p of inScope) {
|
||||
const key = p.parent_id || 'root';
|
||||
if (!byParent.has(key)) byParent.set(key, []);
|
||||
byParent.get(key).push(p);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup>
|
||||
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';
|
||||
import Icon from '../components/Icon.vue';
|
||||
|
||||
const L = useLocaleStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const tab = ref('overview');
|
||||
|
||||
const counts = ref({});
|
||||
const users = ref([]);
|
||||
const newUser = reactive({ name: '', email: '', password: '', is_admin: false });
|
||||
const userMsg = ref('');
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<h2><Icon name="shield" :size="22" /> {{ L.t('admin.title') }}</h2>
|
||||
<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'"><Icon name="users" :size="16" /> {{ L.t('teams.title') }}</button>
|
||||
<button :class="{ ghost: tab !== 'groups' }" @click="tab = 'groups'"><Icon name="key" :size="16" /> {{ L.t('groups.title') }}</button>
|
||||
</div>
|
||||
|
||||
<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><Icon name="key" :size="14" /> {{ 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>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
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 Icon from '../components/Icon.vue';
|
||||
|
||||
const ws = useWorkspaceStore();
|
||||
const L = useLocaleStore();
|
||||
const router = useRouter();
|
||||
|
||||
const cursor = ref(new Date());
|
||||
cursor.value.setDate(1);
|
||||
const tasks = ref([]);
|
||||
|
||||
const year = computed(() => cursor.value.getFullYear());
|
||||
const month = computed(() => cursor.value.getMonth());
|
||||
const monthName = computed(() =>
|
||||
cursor.value.toLocaleDateString(L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US', {
|
||||
month: 'long', year: 'numeric',
|
||||
}),
|
||||
);
|
||||
|
||||
const cells = computed(() => {
|
||||
const first = new Date(year.value, month.value, 1);
|
||||
const startDay = (first.getDay() + 6) % 7; // Monday first
|
||||
const days = new Date(year.value, month.value + 1, 0).getDate();
|
||||
const arr = [];
|
||||
for (let i = 0; i < startDay; i++) arr.push(null);
|
||||
for (let d = 1; d <= days; d++) arr.push(new Date(year.value, month.value, d));
|
||||
return arr;
|
||||
});
|
||||
|
||||
function dayKey(d) {
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
}
|
||||
const byDay = computed(() => {
|
||||
const map = {};
|
||||
for (const t of tasks.value) {
|
||||
if (!t.due_at) continue;
|
||||
const d = new Date(t.due_at);
|
||||
const k = dayKey(d);
|
||||
if (!map[k]) map[k] = [];
|
||||
map[k].push(t);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function isToday(d) {
|
||||
const n = new Date();
|
||||
return d && d.toDateString() === n.toDateString();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!ws.currentId) return;
|
||||
const from = new Date(year.value, month.value, 1).toISOString();
|
||||
const to = new Date(year.value, month.value + 1, 0, 23, 59).toISOString();
|
||||
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params: { from, to } });
|
||||
tasks.value = data.tasks;
|
||||
}
|
||||
|
||||
function prev() { cursor.value = new Date(year.value, month.value - 1, 1); }
|
||||
function next() { cursor.value = new Date(year.value, month.value + 1, 1); }
|
||||
function goToday() { const n = new Date(); cursor.value = new Date(n.getFullYear(), n.getMonth(), 1); }
|
||||
|
||||
watch(cursor, load);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<h2 style="margin: 0"><Icon name="calendar" :size="22" /> {{ L.t('cal.title') }} — {{ monthName }}</h2>
|
||||
<div class="row">
|
||||
<button class="ghost" @click="prev">◀</button>
|
||||
<button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button>
|
||||
<button class="ghost" @click="next">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-head" v-for="d in ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']" :key="d">{{ d }}</div>
|
||||
<div v-for="(d, i) in cells" :key="i" class="cal-cell" :class="{ today: isToday(d) }">
|
||||
<template v-if="d">
|
||||
<div class="cal-day">{{ d.getDate() }}</div>
|
||||
<div
|
||||
v-for="t in (byDay[dayKey(d)] || [])" :key="t.id"
|
||||
class="cal-task" :class="{ done: t.status === 'done' }"
|
||||
:title="t.title"
|
||||
@click="router.push('/tasks')"
|
||||
>
|
||||
{{ t.status === 'done' ? '✅' : '▸' }} {{ t.title }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">▸ {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import api from '../api';
|
||||
import { uploadImage } from '../files';
|
||||
import { connectSocket, getSocket } from '../socket';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import { usePresenceStore } from '../stores/presence';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import ChatMessage from '../components/ChatMessage.vue';
|
||||
import UserSearch from '../components/UserSearch.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
import Icon from '../components/Icon.vue';
|
||||
|
||||
const ws = useWorkspaceStore();
|
||||
const auth = useAuthStore();
|
||||
const L = useLocaleStore();
|
||||
const presence = usePresenceStore();
|
||||
|
||||
const tab = ref('chats'); // 'chats' | 'teams'
|
||||
// DM state
|
||||
const convs = ref([]);
|
||||
const activeConv = ref(null);
|
||||
const dmMessages = ref([]);
|
||||
const dmTyping = ref('');
|
||||
// Team state
|
||||
const activeWs = ref(null);
|
||||
const teamMessages = ref([]);
|
||||
const teamTyping = ref('');
|
||||
// shared
|
||||
const body = ref('');
|
||||
const search = ref('');
|
||||
const searchHits = ref([]);
|
||||
const box = ref(null);
|
||||
let typingTimer = null;
|
||||
let typeThrottle = 0;
|
||||
|
||||
const visibleDm = computed(() => {
|
||||
void dmTyping.value;
|
||||
return dmMessages.value;
|
||||
});
|
||||
|
||||
function scrollDown() {
|
||||
nextTick(() => {
|
||||
if (box.value) box.value.scrollTop = box.value.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- DMs ---------- */
|
||||
async function loadConvs() {
|
||||
const { data } = await api.get('/dm');
|
||||
convs.value = data.conversations;
|
||||
}
|
||||
async function openConv(id) {
|
||||
activeConv.value = id;
|
||||
const { data } = await api.get(`/dm/${id}/messages`);
|
||||
dmMessages.value = data.messages;
|
||||
connectSocket()?.emit('dm:join', { conversationId: id });
|
||||
loadConvs(); // refresh unread badges
|
||||
scrollDown();
|
||||
}
|
||||
async function startDm(user) {
|
||||
const { data } = await api.post('/dm', { user_id: user.id });
|
||||
await loadConvs();
|
||||
openConv(data.conversation.id);
|
||||
}
|
||||
async function sendDm(file = null) {
|
||||
let attachment = '';
|
||||
if (file) attachment = await uploadImage(file);
|
||||
const text = body.value.trim();
|
||||
if (!text && !attachment) return;
|
||||
body.value = '';
|
||||
await api.post(`/dm/${activeConv.value}/messages`, { body: text || '📎', attachment });
|
||||
}
|
||||
function onDmMsg({ message }) {
|
||||
if (message.conversation_id !== activeConv.value) {
|
||||
loadConvs();
|
||||
return;
|
||||
}
|
||||
dmMessages.value.push(message);
|
||||
loadConvs();
|
||||
scrollDown();
|
||||
}
|
||||
function onDmEdited({ message }) {
|
||||
const i = dmMessages.value.findIndex((m) => m.id === message.id);
|
||||
if (i >= 0) Object.assign(dmMessages.value[i], message);
|
||||
}
|
||||
function onDmTyping({ name }) {
|
||||
dmTyping.value = `${name} ${L.t('chat.typing')}`;
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(() => (dmTyping.value = ''), 1500);
|
||||
}
|
||||
function emitDmTyping() {
|
||||
const now = Date.now();
|
||||
if (now - typeThrottle < 2000 || !activeConv.value) return;
|
||||
typeThrottle = now;
|
||||
connectSocket()?.emit('dm:typing', { conversationId: activeConv.value, name: auth.user?.name });
|
||||
}
|
||||
|
||||
/* ---------- Team chats ---------- */
|
||||
async function openTeam(wsId) {
|
||||
activeWs.value = wsId;
|
||||
const { data } = await api.get(`/chat/${wsId}`);
|
||||
teamMessages.value = data.messages;
|
||||
connectSocket()?.emit('chat:join', { workspaceId: wsId });
|
||||
scrollDown();
|
||||
}
|
||||
async function sendTeam(file = null) {
|
||||
if (!activeWs.value) return;
|
||||
let attachment = '';
|
||||
if (file) attachment = await uploadImage(file);
|
||||
const text = body.value.trim();
|
||||
if (!text && !attachment) return;
|
||||
body.value = '';
|
||||
await api.post(`/chat/${activeWs.value}`, { body: text || '📎', attachment });
|
||||
}
|
||||
function onTeamMsg({ message }) {
|
||||
if (message.workspace_id !== activeWs.value) return;
|
||||
teamMessages.value.push(message);
|
||||
scrollDown();
|
||||
}
|
||||
function onTeamEdited({ message }) {
|
||||
const i = teamMessages.value.findIndex((m) => m.id === message.id);
|
||||
if (i >= 0) Object.assign(teamMessages.value[i], message);
|
||||
}
|
||||
function onTeamTyping({ name }) {
|
||||
teamTyping.value = `${name} ${L.t('chat.typing')}`;
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(() => (teamTyping.value = ''), 1500);
|
||||
}
|
||||
function emitTeamTyping() {
|
||||
const now = Date.now();
|
||||
if (now - typeThrottle < 2000 || !activeWs.value) return;
|
||||
typeThrottle = now;
|
||||
connectSocket()?.emit('chat:typing', { workspaceId: activeWs.value, name: auth.user?.name });
|
||||
}
|
||||
function onReaction({ target_type, target_id, reactions }) {
|
||||
const list = target_type === 'dm' ? dmMessages.value : teamMessages.value;
|
||||
const m = list.find((x) => x.id === target_id);
|
||||
if (m) m.reactions = reactions;
|
||||
}
|
||||
function onDeleted({ id }) {
|
||||
dmMessages.value = dmMessages.value.filter((m) => m.id !== id);
|
||||
teamMessages.value = teamMessages.value.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = search.value.trim();
|
||||
if (q.length < 2) { searchHits.value = []; return; }
|
||||
if (tab.value === 'teams' && activeWs.value) {
|
||||
const { data } = await api.get(`/chat/${activeWs.value}/search`, { params: { q } });
|
||||
searchHits.value = data.messages;
|
||||
} else if (tab.value === 'chats' && activeConv.value) {
|
||||
const { data } = await api.get(`/dm/${activeConv.value}/search`, { params: { q } });
|
||||
searchHits.value = data.messages;
|
||||
}
|
||||
}
|
||||
|
||||
function onFile(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (tab.value === 'chats') sendDm(file);
|
||||
else sendTeam(file);
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
connectSocket();
|
||||
await loadConvs();
|
||||
if (ws.currentId) openTeam(ws.currentId);
|
||||
const s = getSocket();
|
||||
s?.on('dm:message', onDmMsg);
|
||||
s?.on('dm:edited', onDmEdited);
|
||||
s?.on('dm:deleted', onDeleted);
|
||||
s?.on('dm:typing', onDmTyping);
|
||||
s?.on('chat:message', onTeamMsg);
|
||||
s?.on('chat:edited', onTeamEdited);
|
||||
s?.on('chat:deleted', onDeleted);
|
||||
s?.on('chat:typing', onTeamTyping);
|
||||
s?.on('reaction:updated', onReaction);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
const s = getSocket();
|
||||
s?.off('dm:message', onDmMsg);
|
||||
s?.off('dm:edited', onDmEdited);
|
||||
s?.off('dm:deleted', onDeleted);
|
||||
s?.off('dm:typing', onDmTyping);
|
||||
s?.off('chat:message', onTeamMsg);
|
||||
s?.off('chat:edited', onTeamEdited);
|
||||
s?.off('chat:deleted', onDeleted);
|
||||
s?.off('chat:typing', onTeamTyping);
|
||||
s?.off('reaction:updated', onReaction);
|
||||
});
|
||||
watch(activeWs, () => { search.value = ''; searchHits.value = []; });
|
||||
watch(activeConv, () => { search.value = ''; searchHits.value = []; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
<main class="main chat-layout">
|
||||
<div class="row wrap">
|
||||
<button :class="{ ghost: tab !== 'chats' }" @click="tab = 'chats'"><Icon name="mail" :size="16" /> {{ L.t('chat.tabs') }}</button>
|
||||
<button :class="{ ghost: tab !== 'teams' }" @click="tab = 'teams'"><Icon name="users" :size="16" /> {{ L.t('chat.teamTabs') }}</button>
|
||||
<input v-model="search" :placeholder="L.t('chat.search')" @input="doSearch" style="max-width: 220px" />
|
||||
</div>
|
||||
|
||||
<div class="chat-grid">
|
||||
<!-- left: conversation list -->
|
||||
<div class="card chat-list">
|
||||
<template v-if="tab === 'chats'">
|
||||
<div class="side-label">{{ L.t('chat.pickUser') }}</div>
|
||||
<UserSearch @pick="startDm" />
|
||||
<div
|
||||
v-for="c in convs" :key="c.id"
|
||||
class="conv-row" :class="{ active: c.id === activeConv }"
|
||||
@click="openConv(c.id)"
|
||||
>
|
||||
<div class="avatar-wrap">
|
||||
<Avatar :name="c.peer?.name" :avatar="c.peer?.avatar" :size="34" />
|
||||
<span v-if="presence.isOnline(c.peer?.id)" class="online-dot"></span>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0">
|
||||
<strong>{{ c.peer?.name || '?' }}</strong>
|
||||
<div class="muted ellipsis">{{ c.last_message?.body || '…' }}</div>
|
||||
</div>
|
||||
<span v-if="c.unread" class="badge static">{{ c.unread }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="w in ws.workspaces" :key="w.id"
|
||||
class="conv-row" :class="{ active: w.id === activeWs }"
|
||||
@click="openTeam(w.id)"
|
||||
>
|
||||
<div class="avatar-wrap">
|
||||
<span class="avatar" style="width: 34px; height: 34px">👥</span>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0">
|
||||
<strong class="ellipsis">{{ w.name }}</strong>
|
||||
<div class="muted ellipsis">{{ w.team_name ? `TEAM_${w.team_name}` : '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- right: messages -->
|
||||
<div class="card chat-pane">
|
||||
<template v-if="tab === 'chats' && activeConv">
|
||||
<div ref="box" class="chat-box slim">
|
||||
<ChatMessage
|
||||
v-for="m in visibleDm" :key="m.id"
|
||||
:message="m" target-type="dm" base-path="dm"
|
||||
@deleted="(id) => (dmMessages = dmMessages.filter((x) => x.id !== id))"
|
||||
/>
|
||||
<p v-if="!dmMessages.length" class="muted">{{ L.t('chat.empty') }}</p>
|
||||
</div>
|
||||
<p v-if="dmTyping" class="muted typing">{{ dmTyping }}</p>
|
||||
</template>
|
||||
<template v-else-if="tab === 'teams' && activeWs">
|
||||
<div ref="box" class="chat-box slim">
|
||||
<ChatMessage
|
||||
v-for="m in teamMessages" :key="m.id"
|
||||
:message="m" target-type="chat" base-path="chat"
|
||||
@deleted="(id) => (teamMessages = teamMessages.filter((x) => x.id !== id))"
|
||||
/>
|
||||
<p v-if="!teamMessages.length" class="muted">{{ L.t('chat.empty') }}</p>
|
||||
</div>
|
||||
<p v-if="teamTyping" class="muted typing">{{ teamTyping }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">{{ L.t('chat.noConv') }}</p>
|
||||
|
||||
<div v-if="searchHits.length" class="card search-hits">
|
||||
<div v-for="h in searchHits" :key="h.id" class="muted">🔍 {{ h.author_name }}: {{ h.body }}</div>
|
||||
</div>
|
||||
<div v-if="(tab === 'chats' && activeConv) || (tab === 'teams' && activeWs)" class="row" style="margin-top: 8px">
|
||||
<label class="ghost icon-btn attach"><Icon name="attach" /><input type="file" accept="image/*" hidden @change="onFile" /></label>
|
||||
<input
|
||||
v-model="body" :placeholder="L.t('chat.type')"
|
||||
@keyup.enter="tab === 'chats' ? sendDm() : sendTeam()"
|
||||
@input="tab === 'chats' ? emitDmTyping() : emitTeamTyping()"
|
||||
/>
|
||||
<button @click="tab === 'chats' ? sendDm() : sendTeam()"><Icon name="send" :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,27 +1,22 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import api from '../api';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import PageTree from '../components/PageTree.vue';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import GreetingHeader from '../components/GreetingHeader.vue';
|
||||
import InviteModal from '../components/InviteModal.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
import Icon from '../components/Icon.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const router = useRouter();
|
||||
const newWs = ref('');
|
||||
const newPage = ref('');
|
||||
const search = ref('');
|
||||
const results = ref([]);
|
||||
const L = useLocaleStore();
|
||||
const members = ref([]);
|
||||
|
||||
async function init() {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
await ws.fetchWorkspaces();
|
||||
await ws.fetchPages();
|
||||
fetchMembers();
|
||||
}
|
||||
const permRows = ref([]);
|
||||
const showPerms = ref(false);
|
||||
const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
|
||||
|
||||
async function fetchMembers() {
|
||||
if (!ws.currentId) return;
|
||||
@@ -29,78 +24,94 @@ async function fetchMembers() {
|
||||
members.value = data.members;
|
||||
}
|
||||
|
||||
async function switchWs(id) {
|
||||
ws.setCurrent(id);
|
||||
async function fetchPerms() {
|
||||
if (!['owner', 'admin'].includes(ws.role)) return;
|
||||
try {
|
||||
const { data } = await api.get(`/workspaces/${ws.currentId}/permissions`);
|
||||
permRows.value = data.members;
|
||||
} catch {
|
||||
permRows.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function setPerm(userId, perm, mode) {
|
||||
await api.patch(`/workspaces/${ws.currentId}/permissions`, {
|
||||
user_id: userId, permission: perm, mode,
|
||||
});
|
||||
await fetchPerms();
|
||||
}
|
||||
|
||||
function overrideOf(row, perm) {
|
||||
const o = row.overrides || {};
|
||||
if (!(perm in o)) return 'inherit';
|
||||
return o[perm] ? 'allow' : 'deny';
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
await ws.fetchWorkspaces();
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
fetchMembers();
|
||||
}
|
||||
|
||||
async function createWs() {
|
||||
if (!newWs.value.trim()) return;
|
||||
await ws.createWorkspace(newWs.value.trim());
|
||||
newWs.value = '';
|
||||
await ws.fetchPages();
|
||||
fetchMembers();
|
||||
}
|
||||
|
||||
async function createPage(parentId = null) {
|
||||
const title = newPage.value.trim() || 'Untitled';
|
||||
const { data } = await api.post('/pages', { workspace_id: ws.currentId, parent_id: parentId, title });
|
||||
newPage.value = '';
|
||||
await ws.fetchPages();
|
||||
router.push(`/page/${data.page.id}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
onMounted(init);
|
||||
await fetchMembers();
|
||||
await fetchPerms();
|
||||
});
|
||||
watch(
|
||||
() => ws.currentId,
|
||||
async () => {
|
||||
await fetchMembers();
|
||||
await fetchPerms();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>{{ auth.user?.name }}'s notes</strong>
|
||||
<button class="ghost" @click="logout">Logout</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>
|
||||
<div class="row">
|
||||
<input v-model="newWs" placeholder="New workspace" />
|
||||
<button @click="createWs">+</button>
|
||||
</div>
|
||||
<input v-model="search" placeholder="Search pages…" @input="doSearch" />
|
||||
<div v-if="results.length" class="card">
|
||||
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
|
||||
{{ r.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input v-model="newPage" placeholder="New page title" @keyup.enter="createPage()" />
|
||||
<button @click="createPage()">+</button>
|
||||
</div>
|
||||
<PageTree :nodes="ws.tree()" @open="router.push(`/page/${$event}`)" @add-child="createPage($event)" />
|
||||
<div class="muted">Role: {{ ws.role }} · Members: {{ members.length }}</div>
|
||||
</aside>
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<h2>Welcome, {{ auth.user?.name }}</h2>
|
||||
<p class="muted">Select a page on the left, or create one. Invite teammates below to collaborate in real time.</p>
|
||||
<GreetingHeader :name="auth.user?.name" />
|
||||
<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>Members</h4>
|
||||
<div v-for="m in members" :key="m.id" class="row" style="justify-content: space-between; padding: 4px 0">
|
||||
<span>{{ m.name }} <{{ m.email }}></span>
|
||||
<span class="muted">{{ m.role }}</span>
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<h4><Icon name="users" :size="16" /> {{ L.t('side.members') }} ({{ members.length }})</h4>
|
||||
<button
|
||||
v-if="['owner', 'admin'].includes(ws.role)"
|
||||
class="ghost mini" @click="showPerms = !showPerms"
|
||||
>
|
||||
<Icon name="key" :size="14" /> {{ L.t('perm.manage') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="m in members" :key="m.id" class="row member-row">
|
||||
<Avatar :name="m.name" :avatar="m.avatar" :size="28" />
|
||||
<span style="flex: 1">{{ m.name }} <span class="muted"><{{ m.email }}></span></span>
|
||||
<span class="pill">{{ m.role }}</span>
|
||||
</div>
|
||||
<div v-if="showPerms" class="card table-scroll" style="margin-top: 8px">
|
||||
<h4><Icon name="key" :size="16" /> {{ L.t('perm.title') }}</h4>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>{{ L.t('admin.name') }}</th><th v-for="p in PERMS" :key="p"><code>{{ p }}</code></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in permRows" :key="r.user_id">
|
||||
<td><strong>{{ r.name }}</strong></td>
|
||||
<td v-for="p in PERMS" :key="p">
|
||||
<select
|
||||
:value="overrideOf(r, p)"
|
||||
@change="setPerm(r.user_id, p, $event.target.value)"
|
||||
style="max-width: 130px"
|
||||
>
|
||||
<option value="inherit">{{ L.t('perm.inherit') }}</option>
|
||||
<option value="allow">✓ {{ L.t('perm.allow') }}</option>
|
||||
<option value="deny">✕ {{ L.t('perm.deny') }}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Icon from '../components/Icon.vue';
|
||||
|
||||
const L = useLocaleStore();
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
@@ -22,14 +26,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"><Icon name="lock" :size="14" /> {{ L.t('auth.locked') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,16 +3,25 @@ import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import Table from '@tiptap/extension-table';
|
||||
import TableRow from '@tiptap/extension-table-row';
|
||||
import TableCell from '@tiptap/extension-table-cell';
|
||||
import TableHeader from '@tiptap/extension-table-header';
|
||||
import api from '../api';
|
||||
import { connectSocket } from '../socket';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import PageTree from '../components/PageTree.vue';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const L = useLocaleStore();
|
||||
|
||||
const page = ref(null);
|
||||
const comments = ref([]);
|
||||
@@ -26,7 +35,7 @@ let saveTimer = null;
|
||||
let typingTimer = null;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
extensions: [StarterKit, TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell],
|
||||
content: '',
|
||||
editable: true,
|
||||
onUpdate: ({ editor }) => {
|
||||
@@ -44,7 +53,6 @@ async function save(ed = editor.value) {
|
||||
if (!page.value || !canEdit.value || !ed) return;
|
||||
const content_json = ed.getJSON();
|
||||
const content_html = ed.getHTML();
|
||||
// Persist via REST (authoritative) + broadcast via socket for instant sync
|
||||
await api.patch(`/pages/${page.value.id}`, { content_json, content_html }).catch(() => {});
|
||||
socket?.emit('page:edit', { pageId: page.value.id, content_json, content_html });
|
||||
}
|
||||
@@ -56,7 +64,6 @@ async function load() {
|
||||
const { data } = await api.get(`/pages/${id}`);
|
||||
page.value = data.page;
|
||||
comments.value = data.comments;
|
||||
canEdit.value = ['owner', 'admin', 'member'].includes(ws.role) || true;
|
||||
editor.value?.commands.setContent(data.page.content_json?.type ? data.page.content_json : (data.page.content_html || ''));
|
||||
joinRoom(id);
|
||||
} catch (e) {
|
||||
@@ -82,16 +89,18 @@ function joinRoom(pageId) {
|
||||
});
|
||||
socket.on('presence:update', ({ members }) => { presence.value = members; });
|
||||
socket.on('typing', ({ name }) => {
|
||||
typingUser.value = `${name} is typing…`;
|
||||
typingUser.value = `✍️ ${name} …`;
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(() => (typingUser.value = ''), 1500);
|
||||
});
|
||||
socket.on('comment:added', ({ comment }) => comments.value.push(comment));
|
||||
}
|
||||
|
||||
async function saveTitle() {
|
||||
async function saveMeta() {
|
||||
if (!page.value) return;
|
||||
const { data } = await api.patch(`/pages/${page.value.id}`, { title: page.value.title });
|
||||
const { data } = await api.patch(`/pages/${page.value.id}`, {
|
||||
title: page.value.title, book_id: page.value.book_id,
|
||||
});
|
||||
page.value = data.page;
|
||||
ws.fetchPages();
|
||||
}
|
||||
@@ -104,7 +113,7 @@ async function addComment() {
|
||||
}
|
||||
|
||||
async function removePage() {
|
||||
if (!confirm('Delete this page?')) return;
|
||||
if (!confirm('✕?')) return;
|
||||
await api.delete(`/pages/${page.value.id}`);
|
||||
await ws.fetchPages();
|
||||
router.push('/');
|
||||
@@ -112,7 +121,10 @@ async function removePage() {
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
if (!ws.pages.length && ws.currentId) await ws.fetchPages();
|
||||
if (!ws.pages.length && ws.currentId) {
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
}
|
||||
await load();
|
||||
});
|
||||
watch(() => route.params.id, load);
|
||||
@@ -124,46 +136,49 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<button class="ghost" @click="router.push('/')">← Dashboard</button>
|
||||
<PageTree :nodes="ws.tree()" :active-id="route.params.id" @open="router.push(`/page/${$event}`)" @add-child="router.push('/')" />
|
||||
</aside>
|
||||
<Sidebar :active-page-id="route.params.id" />
|
||||
<main class="main" v-if="page">
|
||||
<div class="topbar">
|
||||
<input v-model="page.title" @change="saveTitle" style="font-size: 22px; font-weight: 700" />
|
||||
<button class="ghost" @click="removePage">Delete</button>
|
||||
<div class="topbar wrap">
|
||||
<input v-model="page.title" @change="saveMeta" style="font-size: 22px; font-weight: 700; flex: 1" />
|
||||
<select v-model="page.book_id" @change="saveMeta" style="max-width: 200px" :title="L.t('task.book')">
|
||||
<option :value="null">📄 {{ L.t('task.none') }}</option>
|
||||
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
|
||||
</select>
|
||||
<button class="ghost" @click="removePage">✕</button>
|
||||
</div>
|
||||
<div class="presence" style="margin-bottom: 8px">
|
||||
<span class="muted">Online ({{ presence.length }}):</span>
|
||||
<span v-for="m in presence" :key="m.userId" class="avatar" :title="m.email">{{ (m.name || m.email || '?')[0].toUpperCase() }}</span>
|
||||
<span class="muted">● ({{ presence.length }}):</span>
|
||||
<Avatar v-for="m in presence" :key="m.userId" :name="m.name" :size="26" />
|
||||
<span class="muted" v-if="typingUser">{{ typingUser }}</span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBold().run()">B</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBold().run()"><b>B</b></button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleItalic().run()"><i>I</i></button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">H1</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• List</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().toggleCodeBlock().run()"></></button>
|
||||
<button @click="save()">Save</button>
|
||||
<button @click="save()">✓ {{ L.t('c.save') }}</button>
|
||||
</div>
|
||||
<EditorContent :editor="editor" class="tiptap" />
|
||||
<div class="card" style="margin-top: 16px">
|
||||
<h3>Comments ({{ comments.length }})</h3>
|
||||
<h3>{{ L.t('com.title') }} ({{ comments.length }})</h3>
|
||||
<div v-for="c in comments" :key="c.id" class="comment">
|
||||
<strong>{{ c.author_name || 'Someone' }}</strong>
|
||||
<span class="muted"> · {{ new Date(c.created_at).toLocaleString() }}</span>
|
||||
<p>{{ c.body }}</p>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<input v-model="newComment" placeholder="Write a comment…" @keyup.enter="addComment" />
|
||||
<button @click="addComment">Post</button>
|
||||
<input v-model="newComment" :placeholder="L.t('com.ph')" @keyup.enter="addComment" />
|
||||
<button @click="addComment">{{ L.t('com.post') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<main class="main" v-else>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
<p v-else class="muted">Loading…</p>
|
||||
<p v-else class="muted">{{ L.t('c.loading') }}</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
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';
|
||||
import Icon from '../components/Icon.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'),
|
||||
doing: tasks.value.filter((t) => t.status === 'doing'),
|
||||
done: tasks.value.filter((t) => t.status === 'done'),
|
||||
}));
|
||||
|
||||
function isOverdue(t) {
|
||||
return t.status !== 'done' && t.due_at && new Date(t.due_at) < new Date();
|
||||
}
|
||||
function fmtDate(d) {
|
||||
return d ? new Date(d).toLocaleString() : L.t('task.noDue');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!ws.currentId) return;
|
||||
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', {
|
||||
workspace_id: ws.currentId,
|
||||
title: title.value.trim(),
|
||||
priority: priority.value,
|
||||
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) {
|
||||
const { data } = await api.patch(`/tasks/${t.id}`, { 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>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<h2><Icon name="tasks" :size="22" /> {{ 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" />
|
||||
<input v-model="due" type="datetime-local" :title="L.t('task.due')" />
|
||||
<select v-model="priority" style="max-width: 130px">
|
||||
<option value="low">🟢 {{ L.t('task.low') }}</option>
|
||||
<option value="normal">🟡 {{ L.t('task.normal') }}</option>
|
||||
<option value="high">🔴 {{ L.t('task.high') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row wrap">
|
||||
<select v-model="bookId">
|
||||
<option value="">{{ L.t('task.book') }}: {{ L.t('task.none') }}</option>
|
||||
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
|
||||
</select>
|
||||
<select v-model="pageId">
|
||||
<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>
|
||||
<div class="kanban">
|
||||
<div v-for="col in ['todo', 'doing', 'done']" :key="col" class="kanban-col">
|
||||
<h3>{{ L.t(`task.${col}`) }} ({{ cols[col].length }})</h3>
|
||||
<div v-for="t in cols[col]" :key="t.id" class="card task-card" :class="{ overdue: isOverdue(t) }">
|
||||
<strong>{{ t.title }}</strong>
|
||||
<div class="muted">
|
||||
{{ t.priority === 'high' ? '🔴' : t.priority === 'low' ? '🟢' : '🟡' }}
|
||||
🕒 {{ fmtDate(t.due_at) }}
|
||||
<span v-if="isOverdue(t)" class="overdue-tag">{{ L.t('task.overdue') }}</span>
|
||||
</div>
|
||||
<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>
|
||||
<button v-if="t.status === 'todo'" class="ghost mini" @click="setStatus(t, 'doing')">{{ L.t('task.toDoing') }}</button>
|
||||
<button v-if="t.status === 'doing'" class="ghost mini" @click="setStatus(t, 'done')">{{ L.t('task.toDone') }}</button>
|
||||
<button v-if="t.status === 'done'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.reopen') }}</button>
|
||||
<button class="ghost mini" @click="remove(t)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useTimeStore, formatDuration } from '../stores/time';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
import Icon from '../components/Icon.vue';
|
||||
|
||||
const time = useTimeStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const L = useLocaleStore();
|
||||
|
||||
const total = computed(() =>
|
||||
time.entries.reduce((s, e) => s + (e.duration_seconds || 0), 0),
|
||||
);
|
||||
|
||||
function fmt(s) {
|
||||
return s ? new Date(s).toLocaleString() : '—';
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm('✕?')) return;
|
||||
await time.remove(id);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
if (ws.currentId) await time.fetch(ws.currentId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<h2><Icon name="timer" :size="22" /> {{ L.t('time.title') }}</h2>
|
||||
<div v-if="time.open" class="card running-banner">
|
||||
🔴 {{ L.t('time.running') }} <strong>{{ time.open.description || '…' }}</strong>
|
||||
<span v-if="time.open.book_id" class="muted">📚</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>{{ L.t('time.entries') }} · {{ L.t('time.total') }}: {{ formatDuration(total) }}</h3>
|
||||
<p v-if="!time.entries.length" class="muted">{{ L.t('time.noEntries') }}</p>
|
||||
<table v-else class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ L.t('time.started') }}</th>
|
||||
<th>{{ L.t('time.ended') }}</th>
|
||||
<th>{{ L.t('time.dur') }}</th>
|
||||
<th>{{ L.t('time.book') }}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in time.entries" :key="e.id" :class="{ running: !e.ended_at }">
|
||||
<td>{{ fmt(e.started_at) }}</td>
|
||||
<td>{{ e.ended_at ? fmt(e.ended_at) : `🔴 ${L.t('time.running')}` }}</td>
|
||||
<td><strong>{{ e.duration_seconds != null ? formatDuration(e.duration_seconds) : '…' }}</strong></td>
|
||||
<td>{{ e.book_title ? `📚 ${e.book_title}` : L.t('time.none') }}{{ e.description ? ` — ${e.description}` : '' }}</td>
|
||||
<td><Avatar :name="e.user_name" :size="22" /></td>
|
||||
<td><button class="ghost mini" @click="remove(e.id)">✕</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user