Compare commits

...
4 Commits
Author SHA1 Message Date
x3 4fe56265c9 feat(frontend): chat tabs, flat SVG icons, upload autosave, per-user rights
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
- Chat rework: Chats (private DMs with user search, online dots, unread
  badges) and Team chats tabs; shared message component with reactions,
  edit, image attachments, message search and typing indicators
- Flat modern inline SVG icon set (no external dependency) replaces
  emoji chrome across nav, headers, buttons, tabs and settings
- Uploaded avatar/background images apply instantly (auto-save) with
  confirmation note; per-user permission matrix in dashboard members
- Login localized, invite header uses icon, comment header cleaned
2026-09-22 14:41:42 +02:00
x3 ab751ea7ef feat(backend): DMs, reactions, presence, per-user permissions, chat upgrades
- Private 1:1 conversations: find-or-create, message list (marks read),
  send with notifications, edit/delete, message search, unread counts
- Emoji reactions with toggle endpoint + realtime reaction:updated events
- Global online presence (presence:online) + chat/dm typing indicators
  and dm:join rooms; chat messages gain attachments, edit timestamps,
  search endpoint and edit route
- Per-user permission overrides (allow/deny/inherit) that beat group
  rights; workspace admin endpoints to view and set them
2026-09-22 14:41:42 +02:00
x3 0bb4e5c1cd feat(frontend): bell, chat, uploads, glass, teams UI, fixes
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
- Notification bell with invites (accept/decline refreshes workspaces)
  and unread badge (polling + socket push); team chat view realtime
- Avatar/background uploads in settings + glassmorphism widgets toggle;
  background layer resolves /uploads URLs; full-bleed blurred backdrop
- Sidebar: visible creation errors, working sub-pages (parent_id),
  TEAM badge workspaces, refresh button, chat nav; admin creates users
- Tasks: per-team boards, assignee select with avatars; admin tabs for
  users/teams/groups (team workspaces, permission checkboxes)
- Register view removed (admin-only accounts); login localized;
  prettier emoji set; scaling/overflow + mobile fixes
2026-09-22 14:27:33 +02:00
x3 c3c77333db feat(backend): invites, notifications, chat, uploads, teams, groups, hardening
- Pending workspace invites with accept/decline + bell notifications;
  invite permission extendable via workspace.invite group right
- Notifications API (list/unread, mark read, read-all) + realtime push
  to per-user socket rooms; chat messages notify other members
- Workspace team chat API (list/post/delete) + chat:join socket rooms
- Image uploads (multer, 5MB image-only) served from /uploads
- Teams (managers/members), team-owned workspaces (TEAM badge), team
  task boards (team_id) and task assignees (workspace members only)
- Permission groups with teams.create/edit/invite + workspace.invite,
  default Team Creators seeding, hasPerm/canManageTeam guards
- Public registration disabled after install; admins create users
  (pending invites auto-link); users gain bg_glass setting
- Crash fix: express-async-errors + 400 on malformed IDs instead of
  unhandled rejections killing the server
2026-09-22 14:27:33 +02:00
49 changed files with 2833 additions and 244 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
uploads/
.env .env
*.log *.log
.DS_Store .DS_Store
+10 -1
View File
@@ -15,7 +15,16 @@ light/dark mode. Self-hostable via Node.js or Docker Compose.
- **Pages** — Nested pages (`parent_id`) in workspaces or **books**, icon + cover, - **Pages** — Nested pages (`parent_id`) in workspaces or **books**, icon + cover,
ordering, TipTap rich-text with **tables + checklists**, stored as JSON + HTML. ordering, TipTap rich-text with **tables + checklists**, stored as JSON + HTML.
- **Books** 📚 — Independent containers for pages, collapsible in the sidebar. - **Books** 📚 — Independent containers for pages, collapsible in the sidebar.
- **Tasks** ✅ — Kanban (todo/doing/done), priorities, deadlines, links to books/pages. - **Tasks** ✅ — Kanban (todo/doing/doing→done), priorities, deadlines, **assignees**,
links to books/pages, **per-team boards**.
- **Teams** 👥 — Teams with managers/members, **own workspaces** (`TEAM_"name"` badge),
permission **groups** (e.g. Team Creators: teams.create/edit/invite, workspace.invite).
- **Invites + notifications** 🔔 — Pending invites with accept/decline, bell with unread
badge (polling + realtime push), notifications for invites and chat messages.
- **Team chat** 💬 — Realtime workspace channel via Socket.IO.
- **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. - **Calendar** 🗓️ — Deadlines from tasks appear automatically.
- **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list. - **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list.
- **Settings window** ⚙️ — Profile (name, email, avatar, password), appearance - **Settings window** ⚙️ — Profile (name, email, avatar, password), appearance
+163
View File
@@ -12,10 +12,12 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0", "express-rate-limit": "^7.2.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"socket.io": "^4.7.5" "socket.io": "^4.7.5"
} }
@@ -66,6 +68,12 @@
"node": ">= 0.6" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -135,6 +143,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -173,6 +198,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -209,6 +249,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -447,6 +493,15 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express-rate-limit": {
"version": "7.5.1", "version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
@@ -636,6 +691,12 @@
"node": ">= 0.10" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -796,6 +857,27 @@
"node": ">= 0.6" "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": { "node_modules/morgan": {
"version": "1.12.1", "version": "1.12.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz",
@@ -822,6 +904,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1016,6 +1117,12 @@
"node": ">=0.10.0" "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": { "node_modules/proxy-addr": {
"version": "2.0.8", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1073,6 +1180,27 @@
"node": ">= 0.8" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1362,6 +1490,29 @@
"node": ">= 0.8" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1384,6 +1535,12 @@
"node": ">= 0.6" "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": { "node_modules/undici-types": {
"version": "8.9.0", "version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@@ -1399,6 +1556,12 @@
"node": ">= 0.8" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+2
View File
@@ -15,10 +15,12 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0", "express-rate-limit": "^7.2.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"socket.io": "^4.7.5" "socket.io": "^4.7.5"
} }
+129
View File
@@ -133,6 +133,135 @@ CREATE TABLE IF NOT EXISTS time_entries (
); );
CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id); 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 -- Ensure workspace owner is always a member with role=owner
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
BEGIN BEGIN
+20 -1
View File
@@ -1,4 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import 'express-async-errors';
import http from 'node:http'; import http from 'node:http';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
@@ -15,6 +16,14 @@ import bookRoutes from './routes/books.js';
import taskRoutes from './routes/tasks.js'; import taskRoutes from './routes/tasks.js';
import timeRoutes from './routes/time.js'; import timeRoutes from './routes/time.js';
import adminRoutes from './routes/admin.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'; import { initSocket } from './socket.js';
const app = express(); const app = express();
@@ -51,6 +60,15 @@ app.use('/api/books', bookRoutes);
app.use('/api/tasks', taskRoutes); app.use('/api/tasks', taskRoutes);
app.use('/api/time', timeRoutes); app.use('/api/time', timeRoutes);
app.use('/api/admin', adminRoutes); 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) => { app.get('/api/health', async (_req, res) => {
try { try {
@@ -62,7 +80,8 @@ app.get('/api/health', async (_req, res) => {
}); });
app.use((err, _req, res, _next) => { 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' }); res.status(500).json({ error: 'Internal server error' });
}); });
+32
View File
@@ -34,6 +34,38 @@ router.get('/users', async (_req, res) => {
return res.json({ users: rows }); 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 } // PATCH /api/admin/users/:id { is_admin }
router.patch('/users/:id', async (req, res) => { router.patch('/users/:id', async (req, res) => {
const { is_admin } = req.body ?? {}; const { is_admin } = req.body ?? {};
+10 -3
View File
@@ -17,12 +17,19 @@ function publicUser(row) {
theme: row.theme || 'system', theme: row.theme || 'system',
bgImage: row.bg_image || '', bgImage: row.bg_image || '',
bgBlur: row.bg_blur !== false, bgBlur: row.bg_blur !== false,
bgGlass: row.bg_glass !== false,
createdAt: row.created_at, 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) => { 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 ?? {}; const { email = '', name = '', password = '' } = req.body ?? {};
if (!EMAIL_RE.test(String(email).toLowerCase())) { if (!EMAIL_RE.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' }); return res.status(400).json({ error: 'Valid email required' });
@@ -38,7 +45,7 @@ router.post('/register', async (req, res) => {
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' }); if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
const hash = await bcrypt.hash(String(password), 12); const hash = await bcrypt.hash(String(password), 12);
const { rows } = await query( const { rows } = await query(
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at', 'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at',
[String(email).toLowerCase(), String(name).trim(), hash], [String(email).toLowerCase(), String(name).trim(), hash],
); );
const token = signToken(rows[0]); const token = signToken(rows[0]);
@@ -68,7 +75,7 @@ router.post('/login', async (req, res) => {
// GET /api/auth/me // GET /api/auth/me
router.get('/me', requireAuth, async (req, res) => { router.get('/me', requireAuth, async (req, res) => {
const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at FROM users WHERE id = $1', [req.user.id]); const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' }); if (!rows[0]) return res.status(404).json({ error: 'User not found' });
return res.json({ user: publicUser(rows[0]) }); return res.json({ user: publicUser(rows[0]) });
}); });
+122
View File
@@ -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;
+171
View File
@@ -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;
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, ensureDefaultGroups, PERMS } from './guards.js';
const router = Router();
router.use(requireAuth);
async function isWsAdmin(workspaceId, userId) {
const role = await getRole(workspaceId, userId);
return role === 'owner' || role === 'admin';
}
// GET /api/groups/by-workspace/:workspaceId (seeds defaults on first call)
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
await ensureDefaultGroups(req.params.workspaceId);
const { rows } = await query(
`SELECT g.*, (SELECT COUNT(*)::int FROM group_members m WHERE m.group_id = g.id) AS members
FROM groups g WHERE g.workspace_id = $1 ORDER BY g.created_at`,
[req.params.workspaceId],
);
return res.json({ groups: rows, availablePermissions: PERMS });
});
// POST /api/groups { workspace_id, name, permissions[] } (workspace admin+)
router.post('/', async (req, res) => {
const { workspace_id, name = '', permissions = [] } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await isWsAdmin(workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const perms = [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))];
try {
const { rows } = await query(
'INSERT INTO groups (workspace_id, name, permissions) VALUES ($1,$2,$3) RETURNING *',
[workspace_id, String(name).trim(), perms],
);
return res.status(201).json({ group: rows[0] });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Group name taken' });
throw err;
}
});
// PATCH /api/groups/:id { name?, permissions[] } (workspace admin+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { name, permissions } = req.body ?? {};
const perms = permissions !== undefined
? [...new Set((Array.isArray(permissions) ? permissions : []).filter((p) => PERMS.includes(p)))]
: null;
const { rows } = await query(
`UPDATE groups SET name = COALESCE($1, name),
permissions = COALESCE($2, permissions) WHERE id = $3 RETURNING *`,
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
perms,
group.id,
],
);
return res.json({ group: rows[0] });
});
// DELETE /api/groups/:id (workspace admin+)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM groups WHERE id = $1', [group.id]);
return res.status(204).end();
});
// GET /api/groups/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar FROM group_members m
JOIN users u ON u.id = m.user_id WHERE m.group_id = $1`,
[group.id],
);
return res.json({ members: rows });
});
// POST /api/groups/:id/members { user_id } (workspace admin+)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
const { user_id } = req.body ?? {};
if (!(await getRole(group.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
'INSERT INTO group_members (group_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[group.id, user_id],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/groups/:id/members/:userId (workspace admin+)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
const group = found[0];
if (!group) return res.status(404).json({ error: 'Not found' });
if (!(await isWsAdmin(group.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Workspace admin only' });
}
await query('DELETE FROM group_members WHERE group_id = $1 AND user_id = $2', [group.id, req.params.userId]);
return res.status(204).end();
});
export default router;
+44 -2
View File
@@ -13,8 +13,7 @@ export async function getRole(workspaceId, userId) {
} }
/** Require membership; optionally require minimum role. Attaches req.membership. */ /** Require membership; optionally require minimum role. Attaches req.membership. */
export function requireWorkspace(minRole = 'viewer') { export function requireWorkspace(minRole = 'viewer') { return async (req, res, next) => {
return async (req, res, next) => {
const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id; const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id;
if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' }); if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
const role = await getRole(workspaceId, req.user.id); const role = await getRole(workspaceId, req.user.id);
@@ -39,3 +38,46 @@ export async function requireAdmin(req, res, next) {
if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' }); if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
return next(); 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');
}
+54
View File
@@ -0,0 +1,54 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/invites/mine — my pending workspace invites
router.get('/mine', async (req, res) => {
const { rows } = await query(
`SELECT i.*, w.name AS workspace_name, u.name AS inviter_name
FROM workspace_invites i
JOIN workspaces w ON w.id = i.workspace_id
LEFT JOIN users u ON u.id = i.invited_by
WHERE (i.user_id = $1 OR i.email = $2) AND i.status = 'pending'
ORDER BY i.created_at DESC`,
[req.user.id, req.user.email],
);
return res.json({ invites: rows });
});
// POST /api/invites/:id/accept
router.post('/:id/accept', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.status !== 'pending') return res.status(409).json({ error: `Invite ${inv.status}` });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[inv.workspace_id, req.user.id, inv.role],
);
await query(`UPDATE workspace_invites SET status = 'accepted', user_id = $1 WHERE id = $2`, [
req.user.id, inv.id,
]);
return res.json({ ok: true, workspace_id: inv.workspace_id });
});
// POST /api/invites/:id/decline
router.post('/:id/decline', async (req, res) => {
const { rows } = await query('SELECT * FROM workspace_invites WHERE id = $1', [req.params.id]);
const inv = rows[0];
if (!inv) return res.status(404).json({ error: 'Invite not found' });
if (inv.user_id !== req.user.id && inv.email !== req.user.email) {
return res.status(403).json({ error: 'Not your invite' });
}
await query(`UPDATE workspace_invites SET status = 'declined' WHERE id = $1`, [inv.id]);
return res.json({ ok: true });
});
export default router;
+38
View File
@@ -0,0 +1,38 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/notifications/mine?unread=1
router.get('/mine', async (req, res) => {
const params = [req.user.id];
let cond = 'user_id = $1';
if (req.query.unread === '1') cond += ' AND is_read = FALSE';
const { rows } = await query(
`SELECT * FROM notifications WHERE ${cond} ORDER BY created_at DESC LIMIT 50`,
params,
);
const { rows: counts } = await query(
'SELECT COUNT(*)::int AS c FROM notifications WHERE user_id = $1 AND is_read = FALSE',
[req.user.id],
);
return res.json({ notifications: rows, unread: counts[0]?.c ?? 0 });
});
// PATCH /api/notifications/:id/read
router.patch('/:id/read', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2', [
req.params.id, req.user.id,
]);
return res.json({ ok: true });
});
// POST /api/notifications/read-all
router.post('/read-all', async (req, res) => {
await query('UPDATE notifications SET is_read = TRUE WHERE user_id = $1', [req.user.id]);
return res.json({ ok: true });
});
export default router;
+59
View File
@@ -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;
+37 -13
View File
@@ -13,7 +13,7 @@ async function canWrite(userId, workspaceId) {
return role === 'owner' || role === 'admin' || role === 'member'; return role === 'owner' || role === 'admin' || role === 'member';
} }
async function checkLinks(workspaceId, book_id, page_id) { async function checkLinks(workspaceId, book_id, page_id, team_id, assignee_id) {
if (book_id) { if (book_id) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspaceId]); 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 (!rows[0]) return 'Invalid book_id for this workspace';
@@ -22,15 +22,25 @@ async function checkLinks(workspaceId, book_id, page_id) {
const { rows } = await query('SELECT id FROM pages WHERE id = $1 AND workspace_id = $2', [page_id, workspaceId]); 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 (!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; return null;
} }
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to= // GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=&team_id=&assignee=
// from/to filter due_at (used by the calendar).
router.get('/by-workspace/:workspaceId', async (req, res) => { router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id); const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' }); if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { status, from, to } = req.query; const { status, from, to, team_id, assignee } = req.query;
const conds = ['t.workspace_id = $1']; const conds = ['t.workspace_id = $1'];
const params = [req.params.workspaceId]; const params = [req.params.workspaceId];
if (status && STATUSES.includes(String(status))) { if (status && STATUSES.includes(String(status))) {
@@ -45,18 +55,28 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
params.push(new Date(String(to))); params.push(new Date(String(to)));
conds.push(`t.due_at <= $${params.length}`); 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( const { rows } = await query(
`SELECT t.*, b.title AS book_title, p.title AS page_title `SELECT t.*, b.title AS book_title, p.title AS page_title,
tm.name AS team_name, u.name AS assignee_name, u.avatar AS assignee_avatar
FROM tasks t LEFT JOIN books b ON b.id = t.book_id LEFT JOIN pages p ON p.id = t.page_id 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`, WHERE ${conds.join(' AND ')} ORDER BY t.due_at NULLS LAST, t.position, t.created_at`,
params, params,
); );
return res.json({ tasks: rows }); return res.json({ tasks: rows });
}); });
// POST /api/tasks { workspace_id, book_id?, page_id?, title, status?, priority?, due_at? } // POST /api/tasks { workspace_id, book_id?, page_id?, team_id?, assignee_id?, title, ... }
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { workspace_id, book_id = null, page_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {}; const { workspace_id, book_id = null, page_id = null, team_id = null, assignee_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' }); if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await canWrite(req.user.id, workspace_id))) { if (!(await canWrite(req.user.id, workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot create tasks' }); return res.status(403).json({ error: 'Viewers cannot create tasks' });
@@ -66,12 +86,12 @@ router.post('/', async (req, res) => {
} }
if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' }); if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' }); if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
const linkErr = await checkLinks(workspace_id, book_id, page_id); const linkErr = await checkLinks(workspace_id, book_id, page_id, team_id, assignee_id);
if (linkErr) return res.status(400).json({ error: linkErr }); if (linkErr) return res.status(400).json({ error: linkErr });
const { rows } = await query( const { rows } = await query(
`INSERT INTO tasks (workspace_id, book_id, page_id, title, status, priority, due_at, created_by) `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) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[workspace_id, book_id, page_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id], [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] }); return res.status(201).json({ task: rows[0] });
}); });
@@ -84,7 +104,7 @@ router.patch('/:id', async (req, res) => {
if (!(await canWrite(req.user.id, task.workspace_id))) { if (!(await canWrite(req.user.id, task.workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot edit' }); return res.status(403).json({ error: 'Viewers cannot edit' });
} }
const { title, status, priority, due_at, book_id, page_id, position } = req.body ?? {}; const { title, status, priority, due_at, book_id, page_id, team_id, assignee_id, position } = req.body ?? {};
if (status !== undefined && !STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' }); if (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 (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)) { if (title !== undefined && (String(title).trim().length < 1 || String(title).length > 300)) {
@@ -92,7 +112,9 @@ router.patch('/:id', async (req, res) => {
} }
const nextBook = book_id !== undefined ? book_id : task.book_id; const nextBook = book_id !== undefined ? book_id : task.book_id;
const nextPage = page_id !== undefined ? page_id : task.page_id; const nextPage = page_id !== undefined ? page_id : task.page_id;
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage); const nextTeam = team_id !== undefined ? team_id : task.team_id;
const nextAssignee = assignee_id !== undefined ? assignee_id : task.assignee_id;
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage, nextTeam, nextAssignee);
if (linkErr) return res.status(400).json({ error: linkErr }); if (linkErr) return res.status(400).json({ error: linkErr });
const sets = []; const sets = [];
const params = []; const params = [];
@@ -103,6 +125,8 @@ router.patch('/:id', async (req, res) => {
if (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null); if (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null);
if (book_id !== undefined) push('book_id', book_id); if (book_id !== undefined) push('book_id', book_id);
if (page_id !== undefined) push('page_id', page_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 (position !== undefined) push('position', Number(position));
if (sets.length === 0) return res.json({ task }); if (sets.length === 0) return res.json({ task });
params.push(task.id); params.push(task.id);
+153
View File
@@ -0,0 +1,153 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, hasPerm, canManageTeam, ensureDefaultGroups } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/teams/by-workspace/:workspaceId (with member counts)
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
`SELECT t.*, (SELECT COUNT(*)::int FROM team_members m WHERE m.team_id = t.id) AS members,
EXISTS(SELECT 1 FROM team_members m WHERE m.team_id = t.id AND m.user_id = $2) AS joined
FROM teams t WHERE t.workspace_id = $1 ORDER BY t.created_at`,
[req.params.workspaceId, req.user.id],
);
return res.json({ teams: rows });
});
// POST /api/teams { workspace_id, name, icon? } (teams.create perm; creator becomes manager)
router.post('/', async (req, res) => {
const { workspace_id, name = '', icon = '👥' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await hasPerm(workspace_id, req.user.id, 'teams.create'))) {
return res.status(403).json({ error: 'No permission to create teams' });
}
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'INSERT INTO teams (workspace_id, name, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
[workspace_id, String(name).trim(), String(icon).slice(0, 20) || '👥', req.user.id],
);
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,'manager')
ON CONFLICT DO NOTHING`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ team: { ...rows[0], members: 1 } });
});
// PATCH /api/teams/:id { name?, icon? } (manager or teams.edit)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
const { name, icon } = req.body ?? {};
const { rows } = await query(
'UPDATE teams SET name = COALESCE($1, name), icon = COALESCE($2, icon) WHERE id = $3 RETURNING *',
[
name !== undefined ? String(name).trim().slice(0, 120) : null,
icon !== undefined ? String(icon).slice(0, 20) : null,
team.id,
],
);
return res.json({ team: rows[0] });
});
// DELETE /api/teams/:id (manager or teams.edit; linked workspaces keep running, badge cleared)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM teams WHERE id = $1', [team.id]);
return res.status(204).end();
});
// GET /api/teams/:id/members
router.get('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await getRole(team.workspace_id, req.user.id))) {
return res.status(403).json({ error: 'Not a workspace member' });
}
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.avatar, m.role FROM team_members m
JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at`,
[team.id],
);
return res.json({ members: rows });
});
// POST /api/teams/:id/members { user_id, role? } (manager or teams.invite; user must be workspace member)
router.post('/:id/members', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'No permission to invite to teams' });
const { user_id, role = 'member' } = req.body ?? {};
if (!['manager', 'member'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (!(await getRole(team.workspace_id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member first' });
}
await query(
`INSERT INTO team_members (team_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[team.id, user_id, role],
);
return res.status(201).json({ ok: true });
});
// DELETE /api/teams/:id/members/:userId (manager or teams.invite)
router.delete('/:id/members/:userId', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
const manager = await canManageTeam(team, req.user.id);
const mayInvite = manager || (await hasPerm(team.workspace_id, req.user.id, 'teams.invite'));
if (!mayInvite) return res.status(403).json({ error: 'Forbidden' });
await query('DELETE FROM team_members WHERE team_id = $1 AND user_id = $2', [team.id, req.params.userId]);
return res.status(204).end();
});
// POST /api/teams/:id/workspace { name } — team creates its own workspace.
// The UI shows a TEAM_"name" badge behind the workspace name.
router.post('/:id/workspace', async (req, res) => {
const { rows: found } = await query('SELECT * FROM teams WHERE id = $1', [req.params.id]);
const team = found[0];
if (!team) return res.status(404).json({ error: 'Not found' });
if (!(await canManageTeam(team, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
const { name = '' } = req.body ?? {};
if (String(name).trim().length < 1 || String(name).length > 120) {
return res.status(400).json({ error: 'Name must be 1-120 chars' });
}
const { rows } = await query(
'INSERT INTO workspaces (name, owner_id, team_id) VALUES ($1,$2,$3) RETURNING *',
[String(name).trim(), req.user.id, team.id],
);
await ensureDefaultGroups(rows[0].id);
// Add all team members to the new workspace (as members)
const { rows: tm } = await query('SELECT user_id FROM team_members WHERE team_id = $1', [team.id]);
for (const m of tm) {
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,'member')
ON CONFLICT DO NOTHING`,
[rows[0].id, m.user_id],
);
}
await query(
`UPDATE workspace_members SET role = 'owner' WHERE workspace_id = $1 AND user_id = $2`,
[rows[0].id, req.user.id],
);
return res.status(201).json({ workspace: { ...rows[0], team_name: team.name } });
});
export default router;
+39
View File
@@ -0,0 +1,39 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
const uploadDir = path.join(process.cwd(), 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname || '').toLowerCase().slice(0, 8);
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'));
},
});
// POST /api/upload — avatar / background image upload (returns relative URL)
router.post('/', requireAuth, (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || 'Upload failed' });
if (!req.file) return res.status(400).json({ error: 'No file' });
return res.status(201).json({ url: `/uploads/${req.file.filename}` });
});
});
export default router;
+8 -5
View File
@@ -36,7 +36,7 @@ router.get('/discover', async (req, res) => {
// PATCH /api/users/me — profile + appearance settings // PATCH /api/users/me — profile + appearance settings
router.patch('/me', async (req, res) => { router.patch('/me', async (req, res) => {
const { name, email, avatar, locale, theme, bg_image, bg_blur } = req.body ?? {}; const { name, email, avatar, locale, theme, bg_image, bg_blur, bg_glass } = req.body ?? {};
if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) { if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) {
return res.status(400).json({ error: 'Name must be 1-100 chars' }); return res.status(400).json({ error: 'Name must be 1-100 chars' });
} }
@@ -64,9 +64,10 @@ router.patch('/me', async (req, res) => {
locale = COALESCE($4, locale), locale = COALESCE($4, locale),
theme = COALESCE($5, theme), theme = COALESCE($5, theme),
bg_image = COALESCE($6, bg_image), bg_image = COALESCE($6, bg_image),
bg_blur = COALESCE($7, bg_blur) bg_blur = COALESCE($7, bg_blur),
WHERE id = $8 bg_glass = COALESCE($8, bg_glass)
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at`, 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, name !== undefined ? String(name).trim() : null,
email !== undefined ? String(email).toLowerCase() : null, email !== undefined ? String(email).toLowerCase() : null,
@@ -75,6 +76,7 @@ router.patch('/me', async (req, res) => {
theme !== undefined ? theme : null, theme !== undefined ? theme : null,
bg_image !== undefined ? String(bg_image).slice(0, 2000) : null, bg_image !== undefined ? String(bg_image).slice(0, 2000) : null,
bg_blur !== undefined ? Boolean(bg_blur) : null, bg_blur !== undefined ? Boolean(bg_blur) : null,
bg_glass !== undefined ? Boolean(bg_glass) : null,
req.user.id, req.user.id,
], ],
); );
@@ -83,7 +85,8 @@ router.patch('/me', async (req, res) => {
user: { user: {
id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin, id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin,
avatar: u.avatar || '', locale: u.locale, theme: u.theme, avatar: u.avatar || '', locale: u.locale, theme: u.theme,
bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false, createdAt: u.created_at, bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false,
bgGlass: u.bg_glass !== false, createdAt: u.created_at,
}, },
}); });
} catch (err) { } catch (err) {
+81 -12
View File
@@ -1,16 +1,18 @@
import { Router } from 'express'; import { Router } from 'express';
import { query } from '../config/db.js'; import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.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(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
// GET /api/workspaces — list my workspaces // GET /api/workspaces — list my workspaces (with team badge info)
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { rows } = await query( 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 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`, WHERE m.user_id = $1 ORDER BY w.updated_at DESC`,
[req.user.id], [req.user.id],
); );
@@ -27,6 +29,7 @@ router.post('/', async (req, res) => {
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *', 'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
[String(name).trim(), req.user.id], [String(name).trim(), req.user.id],
); );
await ensureDefaultGroups(rows[0].id);
return res.status(201).json({ workspace: rows[0] }); return res.status(201).json({ workspace: rows[0] });
}); });
@@ -67,20 +70,86 @@ router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
return res.json({ members: rows }); return res.json({ members: rows });
}); });
// POST /api/workspaces/:id/members { email, role } (admin+; cannot grant owner) // POST /api/workspaces/:id/members { email, role } — creates a pending invite
router.post('/:id/members', requireWorkspace('admin'), async (req, res) => { // + 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 ?? {}; const { email = '', role = 'member' } = req.body ?? {};
if (!ROLES.includes(role) || role === 'owner') { if (!ROLES.includes(role) || role === 'owner') {
return res.status(400).json({ error: 'Role must be admin, member or viewer' }); 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()]); const targetEmail = String(email).toLowerCase();
if (!users[0]) return res.status(404).json({ error: 'No user with that email (they must register first)' }); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(targetEmail)) {
await query( return res.status(400).json({ error: 'Valid email required' });
`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`, const { rows: users } = await query('SELECT id, name FROM users WHERE email = $1', [targetEmail]);
[req.params.id, users[0].id, role], 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+) // PATCH /api/workspaces/:id/members/:userId { role } (admin+)
+46
View File
@@ -30,6 +30,7 @@ export function initSocket(httpServer, corsOrigin) {
}); });
const presence = new Map(); // room -> Map(socketId -> { userId, email, name }) const presence = new Map(); // room -> Map(socketId -> { userId, email, name })
const online = new Map(); // userId -> connection count (global presence)
function emitPresence(room) { function emitPresence(room) {
const members = [...(presence.get(room)?.values() ?? [])]; const members = [...(presence.get(room)?.values() ?? [])];
@@ -38,6 +39,25 @@ export function initSocket(httpServer, corsOrigin) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
const user = socket.data.user; 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 }) => { socket.on('page:join', async ({ pageId }) => {
try { try {
@@ -94,10 +114,36 @@ export function initSocket(httpServer, corsOrigin) {
socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name }); 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', () => { socket.on('disconnect', () => {
for (const [room, members] of presence) { for (const [room, members] of presence) {
if (members.delete(socket.id)) emitPresence(room); 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()]);
}); });
}); });
+27 -1
View File
@@ -84,7 +84,33 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML).
## Admin (requires `is_admin`) ## Admin (requires `is_admin`)
- `GET /admin/overview` → counts + recent users - `GET /admin/overview` → counts + recent users
- `GET /admin/users`, `PATCH /admin/users/:id {is_admin}`, `DELETE /admin/users/:id` - `GET /admin/users`, `POST /admin/users {name,email,password,is_admin?}` (admins create
accounts; public registration is disabled once installed), `PATCH /admin/users/:id`,
`DELETE /admin/users/:id`
## Invites & notifications
- `POST /workspaces/:id/members {email,role}` → pending invite + notification
(workspace admin+ or `workspace.invite` permission)
- `GET /invites/mine`, `POST /invites/:id/accept`, `POST /invites/:id/decline`
- `GET /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 ## Health
+19 -7
View File
@@ -4,35 +4,47 @@ import { useAuthStore } from './stores/auth';
import { useThemeStore } from './stores/theme'; import { useThemeStore } from './stores/theme';
import { useLocaleStore } from './stores/locale'; import { useLocaleStore } from './stores/locale';
import { useUiStore } from './stores/ui'; import { useUiStore } from './stores/ui';
import { fileUrl } from './files';
import { connectSocket } from './socket';
import SettingsWindow from './components/SettingsWindow.vue'; import SettingsWindow from './components/SettingsWindow.vue';
import TimeTrackerButton from './components/TimeTrackerButton.vue'; import TimeTrackerButton from './components/TimeTrackerButton.vue';
import Bell from './components/Bell.vue';
import Icon from './components/Icon.vue';
const auth = useAuthStore(); const auth = useAuthStore();
const theme = useThemeStore(); const theme = useThemeStore();
const locale = useLocaleStore(); const locale = useLocaleStore();
const ui = useUiStore(); const ui = useUiStore();
const bgRaw = computed(() => auth.user?.bgImage || '');
const bgIsImage = computed(() => /^(https?:\/\/|\/uploads|data:)/.test(bgRaw.value));
const bgStyle = computed(() => { const bgStyle = computed(() => {
const bg = auth.user?.bgImage || ''; if (!bgRaw.value) return {};
if (!bg) return {}; const style = {
const style = { backgroundImage: bg.startsWith('http') ? `url("${bg}")` : bg }; backgroundImage: bgIsImage.value ? `url("${fileUrl(bgRaw.value)}")` : bgRaw.value,
if (auth.user?.bgBlur !== false) style.filter = 'blur(14px) brightness(0.85)'; backgroundSize: 'cover',
backgroundPosition: 'center',
};
if (auth.user?.bgBlur !== false) style.filter = 'blur(16px) brightness(0.9)';
return style; return style;
}); });
const glassOn = computed(() => !!bgRaw.value && auth.user?.bgGlass !== false);
onMounted(() => { onMounted(() => {
locale.set(locale.locale); locale.set(locale.locale);
theme.apply(); theme.apply();
auth.applyPrefs(); auth.applyPrefs();
if (auth.isLoggedIn) connectSocket();
}); });
</script> </script>
<template> <template>
<div> <div :class="{ 'has-bg': !!bgRaw, glass: glassOn }">
<div v-if="bgStyle.backgroundImage" class="bg-layer" :style="bgStyle"></div> <div v-if="bgRaw" class="bg-layer" :style="bgStyle"></div>
<div v-if="auth.isLoggedIn" class="top-right"> <div v-if="auth.isLoggedIn" class="top-right">
<Bell />
<TimeTrackerButton /> <TimeTrackerButton />
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button> <button class="ghost icon-btn" @click="ui.openSettings()" title="Settings"><Icon name="settings" /></button>
</div> </div>
<router-view /> <router-view />
<SettingsWindow /> <SettingsWindow />
+120
View File
@@ -239,9 +239,129 @@ input:focus, select:focus { outline: 2px solid var(--accent); border-color: tran
.toolbar button { padding: 5px 10px; font-size: 13px; } .toolbar button { padding: 5px 10px; font-size: 13px; }
.comment { border-top: 1px solid var(--border); padding: 8px 0; } .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) { @media (max-width: 900px) {
.kanban { grid-template-columns: 1fr; } .kanban { grid-template-columns: 1fr; }
.sidebar { width: 240px; min-width: 240px; } .sidebar { width: 240px; min-width: 240px; }
.settings-body { flex-direction: column; } .settings-body { flex-direction: column; }
.settings-tabs { flex-direction: row; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; } .settings-tabs { flex-direction: row; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; }
} }
@media (max-width: 640px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; min-width: 0; max-height: none; position: static; }
.main { padding: 16px; }
.top-right { top: 8px; right: 8px; }
.clock { font-size: 20px; }
.greeting { flex-direction: column; align-items: flex-start; }
}
+9 -2
View File
@@ -1,13 +1,20 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { fileUrl } from '../files';
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } }); const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
const isUrl = computed(() => (props.avatar || '').startsWith('http')); const src = computed(() => {
const a = props.avatar || '';
if (a.startsWith('http') || a.startsWith('/uploads') || a.startsWith('data:')) {
return fileUrl(a);
}
return '';
});
const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase()); const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase());
</script> </script>
<template> <template>
<span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name"> <span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name">
<img v-if="isUrl" :src="avatar" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" /> <img v-if="src" :src="src" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<span v-else-if="avatar">{{ avatar }}</span> <span v-else-if="avatar">{{ avatar }}</span>
<span v-else>{{ letter }}</span> <span v-else>{{ letter }}</span>
</span> </span>
+108
View File
@@ -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>
+94
View File
@@ -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>
+5 -5
View File
@@ -12,10 +12,10 @@ onUnmounted(() => clearInterval(timer));
const period = computed(() => { const period = computed(() => {
const h = now.value.getHours(); const h = now.value.getHours();
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '🌅' }; if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '☀️' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '🍱' }; if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '☕' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '☀️' }; if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '🌤️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌇' }; if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌆' };
return { key: 'greet.night', emoji: '🌙' }; return { key: 'greet.night', emoji: '🌙' };
}); });
@@ -36,6 +36,6 @@ const date = computed(() =>
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2> <h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p> <p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
</div> </div>
<div class="clock">🕒 {{ clock }}</div> <div class="clock">{{ clock }}</div>
</div> </div>
</template> </template>
+57
View File
@@ -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>
+4 -3
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue';
import api from '../api'; import api from '../api';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import UserSearch from './UserSearch.vue'; import UserSearch from './UserSearch.vue';
import Icon from './Icon.vue';
const props = defineProps({ workspaceId: String }); const props = defineProps({ workspaceId: String });
const emit = defineEmits(['invited']); const emit = defineEmits(['invited']);
@@ -25,7 +26,7 @@ async function invite() {
if (!target) return; if (!target) return;
try { try {
await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value }); await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
msg.value = `🎉 ${target} → ${role.value}`; msg.value = `✨ ${target} → ${role.value}`;
email.value = ''; email.value = '';
picked.value = null; picked.value = null;
emit('invited'); emit('invited');
@@ -37,7 +38,7 @@ async function invite() {
<template> <template>
<div class="card"> <div class="card">
<h4>{{ L.t('inv.title') }}</h4> <h4><Icon name="mail" :size="16" /> {{ L.t('inv.title') }}</h4>
<p class="muted">{{ L.t('inv.hint') }}</p> <p class="muted">{{ L.t('inv.hint') }}</p>
<UserSearch @pick="onPick" /> <UserSearch @pick="onPick" />
<div class="row" style="margin-top: 8px"> <div class="row" style="margin-top: 8px">
@@ -47,7 +48,7 @@ async function invite() {
<option value="member">member</option> <option value="member">member</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
<button @click="invite">✉️ {{ L.t('inv.btn') }}</button> <button @click="invite">{{ L.t('inv.btn') }}</button>
</div> </div>
<p v-if="picked" class="muted">🔎 {{ picked.name }} &lt;{{ picked.email }}&gt;</p> <p v-if="picked" class="muted">🔎 {{ picked.name }} &lt;{{ picked.email }}&gt;</p>
<p v-if="msg" style="color: green">{{ msg }}</p> <p v-if="msg" style="color: green">{{ msg }}</p>
+56 -9
View File
@@ -1,11 +1,13 @@
<script setup> <script setup>
import { reactive, ref, watch } from 'vue'; import { reactive, ref, watch } from 'vue';
import api from '../api'; import api from '../api';
import { uploadImage } from '../files';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import { useThemeStore } from '../stores/theme'; import { useThemeStore } from '../stores/theme';
import { useUiStore } from '../stores/ui'; import { useUiStore } from '../stores/ui';
import Avatar from './Avatar.vue'; import Avatar from './Avatar.vue';
import Icon from './Icon.vue';
const auth = useAuthStore(); const auth = useAuthStore();
const L = useLocaleStore(); const L = useLocaleStore();
@@ -19,7 +21,9 @@ const msg = ref('');
const err = ref(''); const err = ref('');
const bgUrl = ref(''); const bgUrl = ref('');
const bgBlur = ref(true); const bgBlur = ref(true);
const bgGlass = ref(true);
const bgPreset = ref('none'); const bgPreset = ref('none');
const uploading = ref('');
const GRADIENTS = { const GRADIENTS = {
none: '', none: '',
@@ -42,6 +46,7 @@ watch(
([, v]) => v && v === auth.user.bgImage, ([, v]) => v && v === auth.user.bgImage,
)?.[0] || (auth.user.bgImage ? 'custom' : 'none'); )?.[0] || (auth.user.bgImage ? 'custom' : 'none');
bgBlur.value = auth.user.bgBlur !== false; bgBlur.value = auth.user.bgBlur !== false;
bgGlass.value = auth.user.bgGlass !== false;
msg.value = ''; msg.value = '';
err.value = ''; err.value = '';
} }
@@ -106,13 +111,50 @@ async function saveBackground() {
msg.value = ''; msg.value = '';
err.value = ''; err.value = '';
try { try {
const { data } = await api.patch('/users/me', { bg_image: bgValue(), bg_blur: bgBlur.value }); const { data } = await api.patch('/users/me', {
bg_image: bgValue(), bg_blur: bgBlur.value, bg_glass: bgGlass.value,
});
persistUser(data.user); persistUser(data.user);
msg.value = L.t('set.saved'); msg.value = L.t('set.saved');
} catch (e) { } catch (e) {
err.value = e.response?.data?.error || 'Error'; 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> </script>
<template> <template>
@@ -124,15 +166,15 @@ async function saveBackground() {
<span class="dot yellow"></span> <span class="dot yellow"></span>
<span class="dot green"></span> <span class="dot green"></span>
</div> </div>
<strong>⚙️ {{ L.t('set.title') }}</strong> <strong><Icon name="settings" :size="18" /> {{ L.t('set.title') }}</strong>
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button> <button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
</div> </div>
<div class="settings-body"> <div class="settings-body">
<nav class="settings-tabs"> <nav class="settings-tabs">
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'">{{ L.t('set.profile') }}</button> <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'">{{ L.t('set.appearance') }}</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'">{{ L.t('set.language') }}</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'">{{ L.t('set.background') }}</button> <button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'"><Icon name="image" :size="16" /> {{ L.t('set.background') }}</button>
</nav> </nav>
<section class="settings-content"> <section class="settings-content">
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px"> <div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
@@ -146,6 +188,8 @@ async function saveBackground() {
<div class="emoji-row"> <div class="emoji-row">
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span> <span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
</div> </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> <label>{{ L.t('set.name') }}</label>
<input v-model="form.name" /> <input v-model="form.name" />
<label>{{ L.t('set.email') }}</label> <label>{{ L.t('set.email') }}</label>
@@ -162,9 +206,9 @@ async function saveBackground() {
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px"> <div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
<label>{{ L.t('set.theme') }}</label> <label>{{ L.t('set.theme') }}</label>
<div class="row"> <div class="row">
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')">{{ L.t('set.light') }}</button> <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')">{{ L.t('set.dark') }}</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')">{{ L.t('set.system') }}</button> <button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')"><Icon name="desktop" :size="16" /> {{ L.t('set.system') }}</button>
</div> </div>
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p> <p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
</div> </div>
@@ -190,8 +234,11 @@ async function saveBackground() {
</div> </div>
<label>{{ L.t('set.bgUrl') }}</label> <label>{{ L.t('set.bgUrl') }}</label>
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" /> <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> <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="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> <button @click="saveBackground">{{ L.t('set.save') }}</button>
</div> </div>
+65 -22
View File
@@ -8,6 +8,7 @@ import { useLocaleStore } from '../stores/locale';
import { useUiStore } from '../stores/ui'; import { useUiStore } from '../stores/ui';
import PageTree from './PageTree.vue'; import PageTree from './PageTree.vue';
import Avatar from './Avatar.vue'; import Avatar from './Avatar.vue';
import Icon from './Icon.vue';
const props = defineProps({ activePageId: String }); const props = defineProps({ activePageId: String });
const auth = useAuthStore(); const auth = useAuthStore();
@@ -21,6 +22,7 @@ const newPage = ref('');
const newBook = ref(''); const newBook = ref('');
const search = ref(''); const search = ref('');
const results = ref([]); const results = ref([]);
const sideErr = ref('');
const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}')); const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}'));
function toggleBook(id) { function toggleBook(id) {
@@ -28,9 +30,19 @@ function toggleBook(id) {
localStorage.setItem('collapsedBooks', JSON.stringify(collapsed.value)); 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() { async function refresh() {
try {
await ws.fetchWorkspaces();
await ws.fetchBooks(); await ws.fetchBooks();
await ws.fetchPages(); await ws.fetchPages();
} catch (e) {
fail(e);
}
} }
async function switchWs(id) { async function switchWs(id) {
@@ -40,39 +52,60 @@ async function switchWs(id) {
async function createWs() { async function createWs() {
if (!newWs.value.trim()) return; if (!newWs.value.trim()) return;
try {
await ws.createWorkspace(newWs.value.trim()); await ws.createWorkspace(newWs.value.trim());
newWs.value = ''; newWs.value = '';
await refresh(); await refresh();
} catch (e) {
fail(e);
}
} }
async function createPage(bookId = null) { async function createPage(bookId = null, parentId = null) {
try {
const title = newPage.value.trim() || 'Untitled'; const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', { const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, title, workspace_id: ws.currentId, book_id: bookId, parent_id: parentId, title,
}); });
newPage.value = ''; newPage.value = '';
await ws.fetchPages(); await ws.fetchPages();
router.push(`/page/${data.page.id}`); router.push(`/page/${data.page.id}`);
} catch (e) {
fail(e);
}
} }
async function createBook() { async function createBook() {
if (!newBook.value.trim()) return; if (!newBook.value.trim()) return;
try {
await ws.createBook(newBook.value.trim(), '📚'); await ws.createBook(newBook.value.trim(), '📚');
newBook.value = ''; newBook.value = '';
} catch (e) {
fail(e);
}
} }
async function deleteBook(id) { async function deleteBook(id) {
if (!confirm('🗑️?')) return; if (!confirm('✕?')) return;
try {
await api.delete(`/books/${id}`); await api.delete(`/books/${id}`);
await refresh(); await ws.fetchBooks();
await ws.fetchPages();
} catch (e) {
fail(e);
}
} }
async function doSearch() { async function doSearch() {
if (!search.value.trim()) { results.value = []; return; } if (!search.value.trim()) { results.value = []; return; }
try {
const { data } = await api.get('/pages/search/query', { const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId }, params: { q: search.value, workspace_id: ws.currentId },
}); });
results.value = data.results; results.value = data.results;
} catch {
results.value = [];
}
} }
function logout() { function logout() {
@@ -87,24 +120,32 @@ onMounted(refresh);
<aside class="sidebar"> <aside class="sidebar">
<div class="user-chip"> <div class="user-chip">
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" /> <Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
<strong style="flex: 1">{{ auth.user?.name }}</strong> <strong style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis">{{ auth.user?.name }}</strong>
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button> <button class="ghost icon-btn" @click="ui.openSettings()" title="Settings"><Icon name="settings" /></button>
</div> </div>
<select :value="ws.currentId" @change="switchWs($event.target.value)"> <p v-if="sideErr" class="side-error">{{ sideErr }}</p>
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
<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> </select>
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')"><Icon name="refresh" /></button>
</div>
<div class="row"> <div class="row">
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" /> <input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
<button @click="createWs">+</button> <button @click="createWs">+</button>
</div> </div>
<nav class="side-nav"> <nav class="side-nav">
<router-link to="/">🏠 {{ L.t('nav.dashboard') }}</router-link> <router-link to="/"><Icon name="home" /> {{ L.t('nav.dashboard') }}</router-link>
<router-link to="/tasks">✅ {{ L.t('nav.tasks') }}</router-link> <router-link to="/tasks"><Icon name="tasks" /> {{ L.t('nav.tasks') }}</router-link>
<router-link to="/calendar">🗓️ {{ L.t('nav.calendar') }}</router-link> <router-link to="/calendar"><Icon name="calendar" /> {{ L.t('nav.calendar') }}</router-link>
<router-link to="/time">⏱️ {{ L.t('nav.time') }}</router-link> <router-link to="/time"><Icon name="timer" /> {{ L.t('nav.time') }}</router-link>
<router-link v-if="auth.user?.isAdmin" to="/admin">🛡️ {{ L.t('nav.admin') }}</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> </nav>
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" /> <input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
@@ -121,33 +162,35 @@ onMounted(refresh);
</div> </div>
<div v-for="b in ws.books" :key="b.id" class="book"> <div v-for="b in ws.books" :key="b.id" class="book">
<div class="tree-item book-head" @click="toggleBook(b.id)"> <div class="tree-item book-head" @click="toggleBook(b.id)">
<span>{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span> <span class="ellipsis">{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span> <span class="row">
<button class="ghost mini" @click.stop="createPage(b.id)" title="+">+</button> <button class="ghost mini" @click.stop="createPage(b.id, null)" title="+">+</button>
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="🗑️">🗑️</button> <button class="ghost mini" @click.stop="deleteBook(b.id)" title="✕">✕</button>
</span> </span>
</div> </div>
<div v-if="!collapsed[b.id]" class="book-pages"> <div v-if="!collapsed[b.id]" class="book-pages">
<PageTree <PageTree
:nodes="ws.treeFor(b.id)" :active-id="activePageId" :nodes="ws.treeFor(b.id)" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage(b.id)" @open="router.push(`/page/${$event}`)"
@add-child="createPage(b.id, $event)"
/> />
</div> </div>
</div> </div>
<div class="side-label">{{ L.t('side.pages') }}</div> <div class="side-label">{{ L.t('side.pages') }}</div>
<div class="row"> <div class="row">
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage()" /> <input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage(null, null)" />
<button @click="createPage()">+</button> <button @click="createPage(null, null)">+</button>
</div> </div>
<PageTree <PageTree
:nodes="ws.tree()" :active-id="activePageId" :nodes="ws.tree()" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage()" @open="router.push(`/page/${$event}`)"
@add-child="createPage(null, $event)"
/> />
<div class="side-footer"> <div class="side-footer">
<span class="muted">{{ ws.role }}</span> <span class="muted">{{ ws.role }}</span>
<button class="ghost" @click="logout">🚪 {{ L.t('nav.logout') }}</button> <button class="ghost" @click="logout"><Icon name="logout" /> {{ L.t('nav.logout') }}</button>
</div> </div>
</aside> </aside>
</template> </template>
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { useTimeStore, formatDuration } from '../stores/time'; import { useTimeStore, formatDuration } from '../stores/time';
import { useWorkspaceStore } from '../stores/workspace'; import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import Icon from './Icon.vue';
const time = useTimeStore(); const time = useTimeStore();
const ws = useWorkspaceStore(); const ws = useWorkspaceStore();
@@ -49,7 +50,7 @@ async function start() {
@click="toggle" @click="toggle"
:title="L.t('time.title')" :title="L.t('time.title')"
> >
⏱️ {{ time.open ? elapsed : L.t('time.start') }} <Icon name="timer" :size="16" /> {{ time.open ? elapsed : L.t('time.start') }}
</button> </button>
<div v-if="open && !time.open" class="card timer-pop"> <div v-if="open && !time.open" class="card timer-pop">
<input v-model="desc" :placeholder="L.t('time.descPh')" /> <input v-model="desc" :placeholder="L.t('time.descPh')" />
+21
View File
@@ -0,0 +1,21 @@
// Resolve avatar/background paths: absolute URLs pass through,
// relative /uploads/... paths are prefixed with the backend origin.
export function fileUrl(path) {
if (!path) return '';
if (/^https?:\/\//i.test(path) || path.startsWith('data:') || path.startsWith('linear-gradient')) {
return path;
}
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
const origin = apiBase.replace(/\/api\/?$/, '');
return `${origin}${path.startsWith('/') ? path : `/${path}`}`;
}
export async function uploadImage(file) {
const { default: api } = await import('./api');
const form = new FormData();
form.append('file', file);
const { data } = await api.post('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.url; // relative /uploads/... path
}
+49 -8
View File
@@ -1,35 +1,62 @@
export default { export default {
nav: { nav: {
dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung', dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung',
admin: 'Admin', logout: 'Abmelden', admin: 'Admin', logout: 'Abmelden', chat: 'Chat',
}, },
side: { side: {
workspaces: 'Workspaces', newWorkspace: 'Neuer Workspace', searchPh: 'Seiten suchen …', workspaces: 'Workspaces', newWorkspace: 'Neuer Workspace', searchPh: 'Seiten suchen …',
newPagePh: 'Neue Seite', newBookPh: 'Neues Book', books: '📚 Books', pages: '📄 Seiten', newPagePh: 'Neue Seite', newBookPh: 'Neues Book', books: '📚 Books', pages: '📄 Seiten',
members: 'Mitglieder', role: 'Rolle', members: 'Mitglieder', role: 'Rolle', refresh: 'Aktualisieren', teamBadge: 'Team',
},
auth: {
title: 'Willkommen zurück', sub: 'Melde dich an.',
email: 'E-Mail', password: 'Passwort', btn: 'Anmelden',
locked: 'Accounts werden vom Admin erstellt.',
},
bell: {
title: 'Mitteilungen', invites: 'Einladungen', notifs: 'Neuigkeiten',
empty: 'Alles gelesen. ✨', as: 'als', accept: 'Annehmen', decline: 'Ablehnen',
readAll: 'Alle als gelesen markieren',
},
chat: {
title: 'Team-Chat', ph: 'Nachricht schreiben …', send: 'Senden',
empty: 'Noch keine Nachrichten. Sag Hallo! 👋',
},
teams: {
title: 'Teams', new: 'Neues Team', ph: 'Teamname …',
members: 'Mitglieder', makeWs: 'Workspace für Team erstellen',
wsName: 'Workspace-Name …', manager: 'Manager', member: 'Mitglied',
addMember: 'Hinzufügen', pickUser: 'User wählen …',
noTeams: 'Noch keine Teams. Erstelle eins! 🚀',
},
groups: {
title: 'Gruppen & Rechte', new: 'Neue Gruppe', ph: 'Gruppenname …',
members: 'Mitglieder', save: 'Speichern', perms: 'Rechte',
}, },
greet: { greet: {
morning: 'Guten Morgen', midday: 'Mahlzeit', afternoon: 'Guten Tag', morning: 'Guten Morgen', midday: 'Mahlzeit', afternoon: 'Guten Tag',
evening: 'Guten Abend', night: 'Gute Nacht', evening: 'Guten Abend', night: 'Gute Nacht',
}, },
set: { set: {
title: 'Einstellungen', profile: '👤 Profil', appearance: '🎨 Darstellung', title: 'Einstellungen', profile: 'Profil', appearance: 'Darstellung',
language: '🌍 Sprache', background: '🖼️ Hintergrund', language: 'Sprache', background: 'Hintergrund',
name: 'Anzeigename', email: 'E-Mail', avatar: 'Profilbild (Emoji oder Bild-URL)', name: 'Anzeigename', email: 'E-Mail', avatar: 'Profilbild (Emoji oder Bild-URL)',
avatarHint: 'Z. B. 🦊 oder https://…/foto.png', avatarHint: 'Z. B. 🦊 oder https://…/foto.png',
curPw: 'Aktuelles Passwort', newPw: 'Neues Passwort (min. 8 Zeichen)', curPw: 'Aktuelles Passwort', newPw: 'Neues Passwort (min. 8 Zeichen)',
changePw: 'Passwort ändern', pwChanged: 'Passwort geändert ✅', changePw: 'Passwort ändern', pwChanged: 'Passwort geändert ✅',
save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema', save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema',
light: '☀️ Hell', dark: '🌙 Dunkel', system: '🖥️ System', light: 'Hell', dark: 'Dunkel', system: 'System',
bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:', bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:',
bgBlur: 'Hintergrund weichzeichnen (Blur)', close: 'Schließen', bgBlur: 'Hintergrund weichzeichnen (Blur)', bgGlass: 'Widgets als Glas darstellen',
upload: 'Bild hochladen …', autoSaved: 'Wird sofort übernommen ✓', close: 'Schließen',
presetNone: 'Kein Bild', presetCustom: 'Eigene URL', presetNone: 'Kein Bild', presetCustom: 'Eigene URL',
}, },
task: { task: {
title: 'Aufgaben', new: 'Neue Aufgabe', ph: 'Was ist zu tun?', title: 'Aufgaben', new: 'Neue Aufgabe', ph: 'Was ist zu tun?',
todo: '📝 Offen', doing: '🚧 In Arbeit', done: '✅ Erledigt', todo: '📝 Offen', doing: '⏳ In Arbeit', done: '✅ Erledigt',
priority: 'Prio', low: 'Niedrig', normal: 'Normal', high: 'Hoch', priority: 'Prio', low: 'Niedrig', normal: 'Normal', high: 'Hoch',
due: 'Fällig', noDue: 'Ohne Termin', book: 'Book', page: 'Seite', 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 →', none: '—', overdue: 'Überfällig!', toTodo: '← Offen', toDoing: 'In Arbeit →',
toDone: 'Erledigt →', reopen: '↩ Öffnen', toDone: 'Erledigt →', reopen: '↩ Öffnen',
}, },
@@ -47,15 +74,29 @@ export default {
workspaces: 'Workspaces', joined: 'Dabei seit', makeAdmin: 'Zum Admin machen', workspaces: 'Workspaces', joined: 'Dabei seit', makeAdmin: 'Zum Admin machen',
unmake: 'Admin entziehen', del: 'Löschen', confirm: 'Wirklich löschen?', unmake: 'Admin entziehen', del: 'Löschen', confirm: 'Wirklich löschen?',
you: '(du)', books: 'Books', pages: 'Seiten', tasks: 'Aufgaben', time: 'Zeiteinträge', you: '(du)', books: 'Books', pages: 'Seiten', tasks: 'Aufgaben', time: 'Zeiteinträge',
createUser: 'Benutzer anlegen', newPw: 'Passwort', create: 'Anlegen', created: 'Angelegt ✅',
}, },
c: { c: {
save: 'Speichern', cancel: 'Abbrechen', delete: 'Löschen', create: 'Anlegen', save: 'Speichern', cancel: 'Abbrechen', delete: 'Löschen', create: 'Anlegen',
back: 'Zurück', loading: 'Lädt …', back: 'Zurück', loading: 'Lädt …',
}, },
inv: { inv: {
title: '✉️ Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.', title: 'Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.',
ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen', ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen',
pick: 'Person wählen …', 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' }, com: { title: 'Kommentare', ph: 'Kommentar schreiben …', post: 'Posten' },
}; };
+49 -8
View File
@@ -1,35 +1,62 @@
export default { export default {
nav: { nav: {
dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking', dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking',
admin: 'Admin', logout: 'Log out', admin: 'Admin', logout: 'Log out', chat: 'Chat',
}, },
side: { side: {
workspaces: 'Workspaces', newWorkspace: 'New workspace', searchPh: 'Search pages …', workspaces: 'Workspaces', newWorkspace: 'New workspace', searchPh: 'Search pages …',
newPagePh: 'New page', newBookPh: 'New book', books: '📚 Books', pages: '📄 Pages', newPagePh: 'New page', newBookPh: 'New book', books: '📚 Books', pages: '📄 Pages',
members: 'Members', role: 'Role', members: 'Members', role: 'Role', refresh: 'Refresh', teamBadge: 'Team',
},
auth: {
title: 'Welcome back', sub: 'Log in to your workspace.',
email: 'Email', password: 'Password', btn: 'Log in',
locked: 'Accounts are created by an admin.',
},
bell: {
title: 'Notifications', invites: 'Invites', notifs: 'Updates',
empty: 'All caught up. ✨', as: 'as', accept: 'Accept', decline: 'Decline',
readAll: 'Mark all as read',
},
chat: {
title: 'Team chat', ph: 'Write a message …', send: 'Send',
empty: 'No messages yet. Say hi! 👋',
},
teams: {
title: 'Teams', new: 'New team', ph: 'Team name …',
members: 'Members', makeWs: 'Create workspace for team',
wsName: 'Workspace name …', manager: 'Manager', member: 'Member',
addMember: 'Add', pickUser: 'Pick user …',
noTeams: 'No teams yet. Create one! 🚀',
},
groups: {
title: 'Groups & permissions', new: 'New group', ph: 'Group name …',
members: 'Members', save: 'Save', perms: 'Permissions',
}, },
greet: { greet: {
morning: 'Good morning', midday: 'Enjoy your lunch', afternoon: 'Good afternoon', morning: 'Good morning', midday: 'Enjoy your lunch', afternoon: 'Good afternoon',
evening: 'Good evening', night: 'Good night', evening: 'Good evening', night: 'Good night',
}, },
set: { set: {
title: 'Settings', profile: '👤 Profile', appearance: '🎨 Appearance', title: 'Settings', profile: 'Profile', appearance: 'Appearance',
language: '🌍 Language', background: '🖼️ Background', language: 'Language', background: 'Background',
name: 'Display name', email: 'Email', avatar: 'Avatar (emoji or image URL)', name: 'Display name', email: 'Email', avatar: 'Avatar (emoji or image URL)',
avatarHint: 'E.g. 🦊 or https://…/photo.png', avatarHint: 'E.g. 🦊 or https://…/photo.png',
curPw: 'Current password', newPw: 'New password (min. 8 chars)', curPw: 'Current password', newPw: 'New password (min. 8 chars)',
changePw: 'Change password', pwChanged: 'Password changed ✅', changePw: 'Change password', pwChanged: 'Password changed ✅',
save: 'Save', saved: 'Saved ✅', theme: 'Color scheme', save: 'Save', saved: 'Saved ✅', theme: 'Color scheme',
light: '☀️ Light', dark: '🌙 Dark', system: '🖥️ System', light: 'Light', dark: 'Dark', system: 'System',
bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:', bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:',
bgBlur: 'Blur background', close: 'Close', bgBlur: 'Blur background', bgGlass: 'Glassmorphism widgets',
upload: 'Upload image …', autoSaved: 'Applied instantly ✓', close: 'Close',
presetNone: 'No image', presetCustom: 'Custom URL', presetNone: 'No image', presetCustom: 'Custom URL',
}, },
task: { task: {
title: 'Tasks', new: 'New task', ph: 'What needs doing?', title: 'Tasks', new: 'New task', ph: 'What needs doing?',
todo: '📝 Todo', doing: '🚧 Doing', done: '✅ Done', todo: '📝 Todo', doing: '⏳ Doing', done: '✅ Done',
priority: 'Priority', low: 'Low', normal: 'Normal', high: 'High', priority: 'Priority', low: 'Low', normal: 'Normal', high: 'High',
due: 'Due', noDue: 'No due date', book: 'Book', page: 'Page', due: 'Due', noDue: 'No due date', book: 'Book', page: 'Page',
team: 'Team', assignee: 'Assignee', allTeams: 'All teams',
none: '—', overdue: 'Overdue!', toTodo: '← Todo', toDoing: 'Doing →', none: '—', overdue: 'Overdue!', toTodo: '← Todo', toDoing: 'Doing →',
toDone: 'Done →', reopen: '↩ Reopen', toDone: 'Done →', reopen: '↩ Reopen',
}, },
@@ -47,15 +74,29 @@ export default {
workspaces: 'Workspaces', joined: 'Joined', makeAdmin: 'Make admin', workspaces: 'Workspaces', joined: 'Joined', makeAdmin: 'Make admin',
unmake: 'Remove admin', del: 'Delete', confirm: 'Really delete?', unmake: 'Remove admin', del: 'Delete', confirm: 'Really delete?',
you: '(you)', books: 'Books', pages: 'Pages', tasks: 'Tasks', time: 'Time entries', you: '(you)', books: 'Books', pages: 'Pages', tasks: 'Tasks', time: 'Time entries',
createUser: 'Create user', newPw: 'Password', create: 'Create', created: 'Created ✅',
}, },
c: { c: {
save: 'Save', cancel: 'Cancel', delete: 'Delete', create: 'Create', save: 'Save', cancel: 'Cancel', delete: 'Delete', create: 'Create',
back: 'Back', loading: 'Loading …', back: 'Back', loading: 'Loading …',
}, },
inv: { inv: {
title: '✉️ Invite collaborator', hint: 'Type to search – or enter a full email.', title: 'Invite collaborator', hint: 'Type to search – or enter a full email.',
ph: 'Search name or email …', btn: 'Invite', done: 'Invited', ph: 'Search name or email …', btn: 'Invite', done: 'Invited',
pick: 'Pick a person …', 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' }, com: { title: 'Comments', ph: 'Write a comment …', post: 'Post' },
}; };
+49 -8
View File
@@ -1,35 +1,62 @@
export default { export default {
nav: { nav: {
dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录', dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录',
admin: '管理', logout: '退出登录', admin: '管理', logout: '退出登录', chat: '聊天',
}, },
side: { side: {
workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …', workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …',
newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面', newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面',
members: '成员', role: '角色', members: '成员', role: '角色', refresh: '刷新', teamBadge: '团队',
},
auth: {
title: '欢迎回来', sub: '登录到你的工作区。',
email: '电子邮件', password: '密码', btn: '登录',
locked: '账号由管理员创建。',
},
bell: {
title: '通知', invites: '邀请', notifs: '动态',
empty: '全部已读。✨', as: '以', accept: '接受', decline: '拒绝',
readAll: '全部标为已读',
},
chat: {
title: '团队聊天', ph: '写消息 …', send: '发送',
empty: '还没有消息,打个招呼吧!👋',
},
teams: {
title: '团队', new: '新建团队', ph: '团队名称 …',
members: '成员', makeWs: '为团队创建工作区',
wsName: '工作区名称 …', manager: '管理员', member: '成员',
addMember: '添加', pickUser: '选择用户 …',
noTeams: '还没有团队,创建一个吧!🚀',
},
groups: {
title: '分组与权限', new: '新建分组', ph: '分组名称 …',
members: '成员', save: '保存', perms: '权限',
}, },
greet: { greet: {
morning: '早上好', midday: '午餐愉快', afternoon: '下午好', morning: '早上好', midday: '午餐愉快', afternoon: '下午好',
evening: '晚上好', night: '晚安', evening: '晚上好', night: '晚安',
}, },
set: { set: {
title: '设置', profile: '👤 个人资料', appearance: '🎨 外观', title: '设置', profile: '个人资料', appearance: '外观',
language: '🌍 语言', background: '🖼️ 背景', language: '语言', background: '背景',
name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)', name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)',
avatarHint: '例如 🦊 或 https://…/photo.png', avatarHint: '例如 🦊 或 https://…/photo.png',
curPw: '当前密码', newPw: '新密码(至少 8 位)', curPw: '当前密码', newPw: '新密码(至少 8 位)',
changePw: '修改密码', pwChanged: '密码已修改 ✅', changePw: '修改密码', pwChanged: '密码已修改 ✅',
save: '保存', saved: '已保存 ✅', theme: '配色方案', save: '保存', saved: '已保存 ✅', theme: '配色方案',
light: '☀️ 浅色', dark: '🌙 深色', system: '🖥️ 跟随系统', light: '浅色', dark: '深色', system: '跟随系统',
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:', bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
bgBlur: '模糊背景', close: '关闭', bgBlur: '模糊背景', bgGlass: '玻璃拟态组件',
upload: '上传图片 …', autoSaved: '即时生效 ✓', close: '关闭',
presetNone: '无背景', presetCustom: '自定义链接', presetNone: '无背景', presetCustom: '自定义链接',
}, },
task: { task: {
title: '任务', new: '新建任务', ph: '要做什么?', title: '任务', new: '新建任务', ph: '要做什么?',
todo: '📝 待办', doing: '🚧 进行中', done: '✅ 已完成', todo: '📝 待办', doing: '⏳ 进行中', done: '✅ 已完成',
priority: '优先级', low: '低', normal: '中', high: '高', priority: '优先级', low: '低', normal: '中', high: '高',
due: '截止', noDue: '无截止日期', book: '书册', page: '页面', due: '截止', noDue: '无截止日期', book: '书册', page: '页面',
team: '团队', assignee: '负责人', allTeams: '全部团队',
none: '—', overdue: '已逾期!', toTodo: '← 待办', toDoing: '进行中 →', none: '—', overdue: '已逾期!', toTodo: '← 待办', toDoing: '进行中 →',
toDone: '完成 →', reopen: '↩ 重新打开', toDone: '完成 →', reopen: '↩ 重新打开',
}, },
@@ -47,15 +74,29 @@ export default {
workspaces: '工作区', joined: '加入时间', makeAdmin: '设为管理员', workspaces: '工作区', joined: '加入时间', makeAdmin: '设为管理员',
unmake: '取消管理员', del: '删除', confirm: '确认删除?', unmake: '取消管理员', del: '删除', confirm: '确认删除?',
you: '(你)', books: '书册', pages: '页面', tasks: '任务', time: '时间记录', you: '(你)', books: '书册', pages: '页面', tasks: '任务', time: '时间记录',
createUser: '创建用户', newPw: '密码', create: '创建', created: '已创建 ✅',
}, },
c: { c: {
save: '保存', cancel: '取消', delete: '删除', create: '创建', save: '保存', cancel: '取消', delete: '删除', create: '创建',
back: '返回', loading: '加载中 …', back: '返回', loading: '加载中 …',
}, },
inv: { inv: {
title: '✉️ 邀请协作者', hint: '输入搜索,或填写完整邮箱。', title: '邀请协作者', hint: '输入搜索,或填写完整邮箱。',
ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请', ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请',
pick: '选择人员 …', 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: '发送' }, com: { title: '评论', ph: '写评论 …', post: '发送' },
}; };
+2 -2
View File
@@ -2,24 +2,24 @@ import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useSetupStore } from '../stores/setup'; import { useSetupStore } from '../stores/setup';
import Login from '../views/Login.vue'; import Login from '../views/Login.vue';
import Register from '../views/Register.vue';
import Setup from '../views/Setup.vue'; import Setup from '../views/Setup.vue';
import Dashboard from '../views/Dashboard.vue'; import Dashboard from '../views/Dashboard.vue';
import PageEditor from '../views/PageEditor.vue'; import PageEditor from '../views/PageEditor.vue';
import Tasks from '../views/Tasks.vue'; import Tasks from '../views/Tasks.vue';
import Calendar from '../views/Calendar.vue'; import Calendar from '../views/Calendar.vue';
import Time from '../views/Time.vue'; import Time from '../views/Time.vue';
import Chat from '../views/Chat.vue';
import Admin from '../views/Admin.vue'; import Admin from '../views/Admin.vue';
const routes = [ const routes = [
{ path: '/setup', component: Setup }, { path: '/setup', component: Setup },
{ path: '/login', component: Login, meta: { guest: true } }, { path: '/login', component: Login, meta: { guest: true } },
{ path: '/register', component: Register, meta: { guest: true } },
{ path: '/', component: Dashboard, meta: { auth: true } }, { path: '/', component: Dashboard, meta: { auth: true } },
{ path: '/page/:id', component: PageEditor, meta: { auth: true } }, { path: '/page/:id', component: PageEditor, meta: { auth: true } },
{ path: '/tasks', component: Tasks, meta: { auth: true } }, { path: '/tasks', component: Tasks, meta: { auth: true } },
{ path: '/calendar', component: Calendar, meta: { auth: true } }, { path: '/calendar', component: Calendar, meta: { auth: true } },
{ path: '/time', component: Time, 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 } }, { path: '/admin', component: Admin, meta: { auth: true, admin: true } },
]; ];
+6
View File
@@ -1,4 +1,5 @@
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { usePresenceStore } from './stores/presence';
let socket = null; let socket = null;
@@ -9,6 +10,11 @@ export function connectSocket() {
socket?.disconnect(); socket?.disconnect();
const url = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000'; const url = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';
socket = io(url || undefined, { auth: { token } }); socket = io(url || undefined, { auth: { token } });
socket.on('presence:online', (ids) => {
try {
usePresenceStore().online = ids || [];
} catch { /* store not ready */ }
});
return socket; return socket;
} }
+12
View File
@@ -0,0 +1,12 @@
import { defineStore } from 'pinia';
export const usePresenceStore = defineStore('presence', {
state: () => ({
online: [],
}),
actions: {
isOnline(id) {
return (this.online || []).includes(id);
},
},
});
+234 -23
View File
@@ -1,64 +1,197 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import api from '../api'; import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue'; import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue'; import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const L = useLocaleStore(); const L = useLocaleStore();
const ws = useWorkspaceStore();
const tab = ref('overview');
const counts = ref({}); const counts = ref({});
const users = ref([]); const users = ref([]);
const recent = ref([]); const newUser = reactive({ name: '', email: '', password: '', is_admin: false });
const userMsg = ref('');
async function load() { const teams = ref([]);
const newTeam = ref('');
const teamMembers = ref({});
const teamWsName = ref({});
const members = ref([]);
const groups = ref([]);
const availPerms = ref([]);
const newGroup = ref('');
const newPerms = ref([]);
const groupMembers = ref({});
async function loadOverview() {
const { data } = await api.get('/admin/overview'); const { data } = await api.get('/admin/overview');
counts.value = data.counts; counts.value = data.counts;
recent.value = data.recentUsers;
const u = await api.get('/admin/users');
users.value = u.data.users;
} }
async function loadUsers() {
const { data } = await api.get('/admin/users');
users.value = data.users;
}
async function createUser() {
userMsg.value = '';
try {
const { data } = await api.post('/admin/users', { ...newUser });
users.value.push({ ...data.user, workspaces: 0 });
newUser.name = '';
newUser.email = '';
newUser.password = '';
newUser.is_admin = false;
userMsg.value = L.t('admin.created');
} catch (e) {
userMsg.value = e.response?.data?.error || 'Error';
}
}
async function setAdmin(u, val) { async function setAdmin(u, val) {
const { data } = await api.patch(`/admin/users/${u.id}`, { is_admin: val }); const { data } = await api.patch(`/admin/users/${u.id}`, { is_admin: val });
u.is_admin = data.user.is_admin; u.is_admin = data.user.is_admin;
load();
} }
async function removeUser(u) { async function removeUser(u) {
if (!confirm(`${L.t('admin.confirm')} (${u.email})`)) return; if (!confirm(`${L.t('admin.confirm')} (${u.email})`)) return;
await api.delete(`/admin/users/${u.id}`); await api.delete(`/admin/users/${u.id}`);
users.value = users.value.filter((x) => x.id !== u.id); users.value = users.value.filter((x) => x.id !== u.id);
} }
onMounted(load); async function loadTeams() {
if (!ws.currentId) return;
const { data } = await api.get(`/teams/by-workspace/${ws.currentId}`);
teams.value = data.teams;
const m = await api.get(`/workspaces/${ws.currentId}/members`);
members.value = m.data.members;
}
async function createTeam() {
if (!newTeam.value.trim()) return;
const { data } = await api.post('/teams', { workspace_id: ws.currentId, name: newTeam.value.trim() });
teams.value.push(data.team);
newTeam.value = '';
}
async function removeTeam(t) {
if (!confirm('✕?')) return;
await api.delete(`/teams/${t.id}`);
teams.value = teams.value.filter((x) => x.id !== t.id);
}
async function showTeamMembers(t) {
const { data } = await api.get(`/teams/${t.id}/members`);
teamMembers.value[t.id] = data.members;
}
async function addTeamMember(t, userId, role) {
if (!userId) return;
await api.post(`/teams/${t.id}/members`, { user_id: userId, role });
showTeamMembers(t);
}
async function removeTeamMember(t, userId) {
await api.delete(`/teams/${t.id}/members/${userId}`);
showTeamMembers(t);
}
async function makeTeamWs(t) {
const name = teamWsName.value[t.id]?.trim();
if (!name) return;
const { data } = await api.post(`/teams/${t.id}/workspace`, { name });
teamWsName.value[t.id] = '';
await ws.fetchWorkspaces();
alert(`✓ ${data.workspace.name} · TEAM_${t.name}`);
}
async function loadGroups() {
if (!ws.currentId) return;
const { data } = await api.get(`/groups/by-workspace/${ws.currentId}`);
groups.value = data.groups;
availPerms.value = data.availablePermissions;
}
async function createGroup() {
if (!newGroup.value.trim()) return;
const { data } = await api.post('/groups', {
workspace_id: ws.currentId, name: newGroup.value.trim(), permissions: newPerms.value,
});
groups.value.push(data.group);
newGroup.value = '';
newPerms.value = [];
}
async function togglePerm(g, perm) {
const perms = new Set(g.permissions || []);
if (perms.has(perm)) perms.delete(perm);
else perms.add(perm);
const { data } = await api.patch(`/groups/${g.id}`, { permissions: [...perms] });
g.permissions = data.group.permissions;
}
async function removeGroup(g) {
if (!confirm('✕?')) return;
await api.delete(`/groups/${g.id}`);
groups.value = groups.value.filter((x) => x.id !== g.id);
}
async function showGroupMembers(g) {
const { data } = await api.get(`/groups/${g.id}/members`);
groupMembers.value[g.id] = data.members;
}
async function addGroupMember(g, userId) {
if (!userId) return;
await api.post(`/groups/${g.id}/members`, { user_id: userId });
showGroupMembers(g);
}
async function removeGroupMember(g, userId) {
await api.delete(`/groups/${g.id}/members/${userId}`);
showGroupMembers(g);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await loadOverview();
await loadUsers();
await loadTeams();
await loadGroups();
});
</script> </script>
<template> <template>
<div class="layout"> <div class="layout">
<Sidebar /> <Sidebar />
<main class="main"> <main class="main">
<h2>🛡️ {{ L.t('admin.title') }}</h2> <h2><Icon name="shield" :size="22" /> {{ L.t('admin.title') }}</h2>
<h3>{{ L.t('admin.overview') }}</h3> <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="stat-grid">
<div class="card stat">👥<strong>{{ counts.users }}</strong>{{ L.t('admin.users') }}</div> <div class="card stat">👥<strong>{{ counts.users }}</strong>{{ L.t('admin.users') }}</div>
<div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>{{ L.t('nav.dashboard') }}</div> <div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>Workspaces</div>
<div class="card stat">📚<strong>{{ counts.books }}</strong>{{ L.t('admin.books') }}</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.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.tasks }}</strong>{{ L.t('admin.tasks') }}</div>
<div class="card stat">⏱️<strong>{{ counts.timeEntries }}</strong>{{ L.t('admin.time') }}</div> <div class="card stat">⏱️<strong>{{ counts.timeEntries }}</strong>{{ L.t('admin.time') }}</div>
</div> </div>
<h3>{{ L.t('admin.users') }} ({{ users.length }})</h3> </div>
<div class="card">
<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"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th></th> <th></th><th>{{ L.t('admin.name') }}</th><th>{{ L.t('admin.email') }}</th>
<th>{{ L.t('admin.name') }}</th> <th>{{ L.t('admin.role') }}</th><th>{{ L.t('admin.workspaces') }}</th>
<th>{{ L.t('admin.email') }}</th> <th>{{ L.t('admin.joined') }}</th><th></th>
<th>{{ L.t('admin.role') }}</th>
<th>{{ L.t('admin.workspaces') }}</th>
<th>{{ L.t('admin.joined') }}</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -72,12 +205,90 @@ onMounted(load);
<td class="row"> <td class="row">
<button v-if="!u.is_admin" class="ghost mini" @click="setAdmin(u, true)">{{ L.t('admin.makeAdmin') }}</button> <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 v-else class="ghost mini" @click="setAdmin(u, false)">{{ L.t('admin.unmake') }}</button>
<button class="ghost mini" @click="removeUser(u)">🗑️</button> <button class="ghost mini" @click="removeUser(u)">✕</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </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> </main>
</div> </div>
</template> </template>
+4 -3
View File
@@ -5,6 +5,7 @@ import api from '../api';
import { useWorkspaceStore } from '../stores/workspace'; import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue'; import Sidebar from '../components/Sidebar.vue';
import Icon from '../components/Icon.vue';
const ws = useWorkspaceStore(); const ws = useWorkspaceStore();
const L = useLocaleStore(); const L = useLocaleStore();
@@ -77,7 +78,7 @@ onMounted(async () => {
<Sidebar /> <Sidebar />
<main class="main"> <main class="main">
<div class="topbar"> <div class="topbar">
<h2 style="margin: 0">🗓️ {{ L.t('cal.title') }} — {{ monthName }}</h2> <h2 style="margin: 0"><Icon name="calendar" :size="22" /> {{ L.t('cal.title') }} — {{ monthName }}</h2>
<div class="row"> <div class="row">
<button class="ghost" @click="prev">◀</button> <button class="ghost" @click="prev">◀</button>
<button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button> <button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button>
@@ -95,12 +96,12 @@ onMounted(async () => {
:title="t.title" :title="t.title"
@click="router.push('/tasks')" @click="router.push('/tasks')"
> >
{{ t.status === 'done' ? '✅' : '📌' }} {{ t.title }} {{ t.status === 'done' ? '✅' : '▸' }} {{ t.title }}
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<p class="muted">📌 {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p> <p class="muted">▸ {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p>
</main> </main>
</div> </div>
</template> </template>
+291
View File
@@ -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>
+71 -3
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import api from '../api'; import api from '../api';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace'; import { useWorkspaceStore } from '../stores/workspace';
@@ -8,11 +8,15 @@ import Sidebar from '../components/Sidebar.vue';
import GreetingHeader from '../components/GreetingHeader.vue'; import GreetingHeader from '../components/GreetingHeader.vue';
import InviteModal from '../components/InviteModal.vue'; import InviteModal from '../components/InviteModal.vue';
import Avatar from '../components/Avatar.vue'; import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const auth = useAuthStore(); const auth = useAuthStore();
const ws = useWorkspaceStore(); const ws = useWorkspaceStore();
const L = useLocaleStore(); const L = useLocaleStore();
const members = ref([]); const members = ref([]);
const permRows = ref([]);
const showPerms = ref(false);
const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
async function fetchMembers() { async function fetchMembers() {
if (!ws.currentId) return; if (!ws.currentId) return;
@@ -20,7 +24,31 @@ async function fetchMembers() {
members.value = data.members; members.value = data.members;
} }
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 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 () => { onMounted(async () => {
await auth.fetchMe().catch(() => {}); await auth.fetchMe().catch(() => {});
@@ -28,7 +56,15 @@ onMounted(async () => {
await ws.fetchBooks(); await ws.fetchBooks();
await ws.fetchPages(); await ws.fetchPages();
await fetchMembers(); await fetchMembers();
await fetchPerms();
}); });
watch(
() => ws.currentId,
async () => {
await fetchMembers();
await fetchPerms();
},
);
</script> </script>
<template> <template>
@@ -36,15 +72,47 @@ onMounted(async () => {
<Sidebar /> <Sidebar />
<main class="main"> <main class="main">
<GreetingHeader :name="auth.user?.name" /> <GreetingHeader :name="auth.user?.name" />
<p class="muted">🏠 {{ currentName }} · {{ L.t('side.role') || ws.role }}: <strong>{{ ws.role }}</strong></p> <p class="muted">🏠 {{ currentName }} <span v-if="currentTeam" class="pill team-pill">TEAM_{{ currentTeam }}</span> · {{ L.t('side.role') }}: <strong>{{ ws.role }}</strong></p>
<InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" /> <InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" />
<div class="card" style="margin-top: 12px"> <div class="card" style="margin-top: 12px">
<h4>👥 {{ L.t('side.members') }} ({{ members.length }})</h4> <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"> <div v-for="m in members" :key="m.id" class="row member-row">
<Avatar :name="m.name" :avatar="m.avatar" :size="28" /> <Avatar :name="m.name" :avatar="m.avatar" :size="28" />
<span style="flex: 1">{{ m.name }} <span class="muted">&lt;{{ m.email }}&gt;</span></span> <span style="flex: 1">{{ m.name }} <span class="muted">&lt;{{ m.email }}&gt;</span></span>
<span class="pill">{{ m.role }}</span> <span class="pill">{{ m.role }}</span>
</div> </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> </div>
</main> </main>
</div> </div>
+10 -6
View File
@@ -2,6 +2,10 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import Icon from '../components/Icon.vue';
const L = useLocaleStore();
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
@@ -22,14 +26,14 @@ async function submit() {
<template> <template>
<div class="auth-wrap card"> <div class="auth-wrap card">
<h1>Welcome back</h1> <h1>{{ L.t('auth.title') }}</h1>
<p class="muted">Log in to your workspace.</p> <p class="muted">{{ L.t('auth.sub') }}</p>
<form @submit.prevent="submit" style="display: grid; gap: 10px"> <form @submit.prevent="submit" style="display: grid; gap: 10px">
<input v-model="email" type="email" placeholder="Email" required /> <input v-model="email" type="email" :placeholder="L.t('auth.email')" required />
<input v-model="password" type="password" placeholder="Password" required /> <input v-model="password" type="password" :placeholder="L.t('auth.password')" required />
<button type="submit">Login</button> <button type="submit">{{ L.t('auth.btn') }}</button>
</form> </form>
<p v-if="err" style="color: red">{{ err }}</p> <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> </div>
</template> </template>
+6 -6
View File
@@ -113,7 +113,7 @@ async function addComment() {
} }
async function removePage() { async function removePage() {
if (!confirm('🗑️?')) return; if (!confirm('✕?')) return;
await api.delete(`/pages/${page.value.id}`); await api.delete(`/pages/${page.value.id}`);
await ws.fetchPages(); await ws.fetchPages();
router.push('/'); router.push('/');
@@ -144,10 +144,10 @@ onUnmounted(() => {
<option :value="null">📄 {{ L.t('task.none') }}</option> <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> <option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select> </select>
<button class="ghost" @click="removePage">🗑️</button> <button class="ghost" @click="removePage">✕</button>
</div> </div>
<div class="presence" style="margin-bottom: 8px"> <div class="presence" style="margin-bottom: 8px">
<span class="muted">🟢 ({{ presence.length }}):</span> <span class="muted">● ({{ presence.length }}):</span>
<Avatar v-for="m in presence" :key="m.userId" :name="m.name" :size="26" /> <Avatar v-for="m in presence" :key="m.userId" :name="m.name" :size="26" />
<span class="muted" v-if="typingUser">{{ typingUser }}</span> <span class="muted" v-if="typingUser">{{ typingUser }}</span>
</div> </div>
@@ -158,13 +158,13 @@ onUnmounted(() => {
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button> <button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• {{ L.t('side.pages') }}</button> <button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• {{ L.t('side.pages') }}</button>
<button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</button> <button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</button>
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">▦ Table</button> <button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">⊞ Table</button>
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()">&lt;/&gt;</button> <button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()">&lt;/&gt;</button>
<button @click="save()">💾 {{ L.t('c.save') }}</button> <button @click="save()">✓ {{ L.t('c.save') }}</button>
</div> </div>
<EditorContent :editor="editor" class="tiptap" /> <EditorContent :editor="editor" class="tiptap" />
<div class="card" style="margin-top: 16px"> <div class="card" style="margin-top: 16px">
<h3>💬 {{ L.t('com.title') }} ({{ comments.length }})</h3> <h3>{{ L.t('com.title') }} ({{ comments.length }})</h3>
<div v-for="c in comments" :key="c.id" class="comment"> <div v-for="c in comments" :key="c.id" class="comment">
<strong>{{ c.author_name || 'Someone' }}</strong> <strong>{{ c.author_name || 'Someone' }}</strong>
<span class="muted"> · {{ new Date(c.created_at).toLocaleString() }}</span> <span class="muted"> · {{ new Date(c.created_at).toLocaleString() }}</span>
-37
View File
@@ -1,37 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const email = ref('');
const name = ref('');
const password = ref('');
const err = ref('');
const router = useRouter();
const auth = useAuthStore();
async function submit() {
err.value = '';
try {
await auth.register(email.value, name.value, password.value);
router.push('/');
} catch (e) {
err.value = e.response?.data?.error || 'Registration failed';
}
}
</script>
<template>
<div class="auth-wrap card">
<h1>Create account</h1>
<p class="muted">Start your collaborative workspace.</p>
<form @submit.prevent="submit" style="display: grid; gap: 10px">
<input v-model="name" placeholder="Display name" required />
<input v-model="email" type="email" placeholder="Email" required />
<input v-model="password" type="password" placeholder="Password (min 8 chars)" required minlength="8" />
<button type="submit">Register</button>
</form>
<p v-if="err" style="color: red">{{ err }}</p>
<p class="muted">Have an account? <router-link to="/login">Log in</router-link></p>
</div>
</template>
+62 -5
View File
@@ -1,21 +1,28 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import api from '../api'; import api from '../api';
import { useWorkspaceStore } from '../stores/workspace'; import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue'; import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const ws = useWorkspaceStore(); const ws = useWorkspaceStore();
const L = useLocaleStore(); const L = useLocaleStore();
const router = useRouter(); const router = useRouter();
const tasks = ref([]); const tasks = ref([]);
const teams = ref([]);
const members = ref([]);
const teamFilter = ref('');
const title = ref(''); const title = ref('');
const due = ref(''); const due = ref('');
const priority = ref('normal'); const priority = ref('normal');
const bookId = ref(''); const bookId = ref('');
const pageId = ref(''); const pageId = ref('');
const teamId = ref('');
const assigneeId = ref('');
const cols = computed(() => ({ const cols = computed(() => ({
todo: tasks.value.filter((t) => t.status === 'todo'), todo: tasks.value.filter((t) => t.status === 'todo'),
@@ -32,10 +39,19 @@ function fmtDate(d) {
async function load() { async function load() {
if (!ws.currentId) return; if (!ws.currentId) return;
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`); const params = {};
if (teamFilter.value) params.team_id = teamFilter.value;
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params });
tasks.value = data.tasks; 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() { async function create() {
if (!title.value.trim()) return; if (!title.value.trim()) return;
const { data } = await api.post('/tasks', { const { data } = await api.post('/tasks', {
@@ -45,12 +61,16 @@ async function create() {
due_at: due.value || null, due_at: due.value || null,
book_id: bookId.value || null, book_id: bookId.value || null,
page_id: pageId.value || null, page_id: pageId.value || null,
team_id: teamId.value || null,
assignee_id: assigneeId.value || null,
}); });
tasks.value.unshift(data.task); tasks.value.unshift(data.task);
title.value = ''; title.value = '';
due.value = ''; due.value = '';
bookId.value = ''; bookId.value = '';
pageId.value = ''; pageId.value = '';
teamId.value = '';
assigneeId.value = '';
} }
async function setStatus(t, status) { async function setStatus(t, status) {
@@ -58,16 +78,27 @@ async function setStatus(t, status) {
Object.assign(t, data.task); 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) { async function remove(t) {
if (!confirm('🗑️?')) return; if (!confirm('✕?')) return;
await api.delete(`/tasks/${t.id}`); await api.delete(`/tasks/${t.id}`);
tasks.value = tasks.value.filter((x) => x.id !== t.id); tasks.value = tasks.value.filter((x) => x.id !== t.id);
} }
watch(teamFilter, load);
onMounted(async () => { onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces(); if (!ws.workspaces.length) await ws.fetchWorkspaces();
await ws.fetchBooks(); await ws.fetchBooks();
await ws.fetchPages(); await ws.fetchPages();
await loadMeta();
await load(); await load();
}); });
</script> </script>
@@ -76,7 +107,17 @@ onMounted(async () => {
<div class="layout"> <div class="layout">
<Sidebar /> <Sidebar />
<main class="main"> <main class="main">
<h2>✅ {{ L.t('task.title') }}</h2> <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="card new-task">
<div class="row wrap"> <div class="row wrap">
<input v-model="title" :placeholder="L.t('task.ph')" @keyup.enter="create" style="flex: 2" /> <input v-model="title" :placeholder="L.t('task.ph')" @keyup.enter="create" style="flex: 2" />
@@ -96,6 +137,14 @@ onMounted(async () => {
<option value="">{{ L.t('task.page') }}: {{ L.t('task.none') }}</option> <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> <option v-for="p in ws.pages" :key="p.id" :value="p.id">{{ p.title }}</option>
</select> </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> <button @click="create">+ {{ L.t('task.new') }}</button>
</div> </div>
</div> </div>
@@ -112,13 +161,21 @@ onMounted(async () => {
<div class="muted"> <div class="muted">
<span v-if="t.book_title">📚 {{ t.book_title }}</span> <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.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>
<div class="row wrap"> <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, '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 === '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 === '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 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> <button class="ghost mini" @click="remove(t)">✕</button>
</div> </div>
</div> </div>
</div> </div>
+4 -3
View File
@@ -5,6 +5,7 @@ import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue'; import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue'; import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const time = useTimeStore(); const time = useTimeStore();
const ws = useWorkspaceStore(); const ws = useWorkspaceStore();
@@ -19,7 +20,7 @@ function fmt(s) {
} }
async function remove(id) { async function remove(id) {
if (!confirm('🗑️?')) return; if (!confirm('✕?')) return;
await time.remove(id); await time.remove(id);
} }
@@ -33,7 +34,7 @@ onMounted(async () => {
<div class="layout"> <div class="layout">
<Sidebar /> <Sidebar />
<main class="main"> <main class="main">
<h2>⏱️ {{ L.t('time.title') }}</h2> <h2><Icon name="timer" :size="22" /> {{ L.t('time.title') }}</h2>
<div v-if="time.open" class="card running-banner"> <div v-if="time.open" class="card running-banner">
🔴 {{ L.t('time.running') }} <strong>{{ time.open.description || '…' }}</strong> 🔴 {{ L.t('time.running') }} <strong>{{ time.open.description || '…' }}</strong>
<span v-if="time.open.book_id" class="muted">📚</span> <span v-if="time.open.book_id" class="muted">📚</span>
@@ -59,7 +60,7 @@ onMounted(async () => {
<td><strong>{{ e.duration_seconds != null ? formatDuration(e.duration_seconds) : '…' }}</strong></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>{{ 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><Avatar :name="e.user_name" :size="22" /></td>
<td><button class="ghost mini" @click="remove(e.id)">🗑️</button></td> <td><button class="ghost mini" @click="remove(e.id)">✕</button></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>