Compare commits

...
6 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
x3 fb396d313a feat(frontend): settings window, i18n, admin UI, books, tasks, calendar, timer
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
- Settings window with tabs: profile (name, email, avatar, password),
  appearance (light/dark/system), language (de/en/zh), background
  (gradient presets, custom URL, blur toggle); persisted server-side
- i18n dictionaries (de/en/zh) applied across nav, sidebar, tasks,
  calendar, time tracking, admin, invites and comments
- Greeting header with time-of-day message, live clock and date; cooler
  theme, cards, kanban, calendar grid and data-table styling
- Shared sidebar: nav, collapsible books with per-book pages, top-level
  pages, search; user chip opens settings
- Tasks kanban with priorities, overdue flags and book/page links;
  calendar month view fed by task deadlines; time view with entries,
  durations and totals; top-right live timer with optional book link
- User discovery autocomplete in the invite card; editor gains tables
  and checklists; standalone theme buttons removed
2026-09-22 13:56:26 +02:00
x3 b852583499 feat(backend): books, tasks, calendar feed, time tracking, profiles, admin API
- Schema: books, tasks (book/page links, status, priority, due_at),
  time_entries (open entry = running timer), user profile columns
  (avatar, locale, theme, bg_image, bg_blur)
- Books CRUD; pages can live in a book (book_id, validated same-workspace)
- Tasks CRUD with status filter and due_at range query for the calendar
- Time API: start (409 if running), stop (computes duration), list with
  open timer, update, delete
- Users: fuzzy discovery for invites, PATCH /me profile/appearance,
  password change; members list now includes avatars
- Admin API (is_admin guard): overview counts, user list, grant/revoke
  admin, delete user (self-demote/delete blocked)
2026-09-22 13:56:25 +02:00
62 changed files with 4938 additions and 266 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
uploads/
.env
*.log
.DS_Store
+19 -2
View File
@@ -12,8 +12,25 @@ light/dark mode. Self-hostable via Node.js or Docker Compose.
- **Auth** — Register / login with JWT (access token), bcrypt password hashing.
- **Workspaces** — Create workspaces, invite members by email (`owner` / `admin` /
`member` / `viewer`), change roles, remove members.
- **Pages** — Nested pages (`parent_id`), icon + cover, ordering, TipTap rich-text
content stored as JSON + rendered HTML.
- **Pages** — Nested pages (`parent_id`) in workspaces or **books**, icon + cover,
ordering, TipTap rich-text with **tables + checklists**, stored as JSON + HTML.
- **Books** 📚 — Independent containers for pages, collapsible in the sidebar.
- **Tasks** ✅ — Kanban (todo/doing/doing→done), priorities, deadlines, **assignees**,
links to books/pages, **per-team boards**.
- **Teams** 👥 — Teams with managers/members, **own workspaces** (`TEAM_"name"` badge),
permission **groups** (e.g. Team Creators: teams.create/edit/invite, workspace.invite).
- **Invites + notifications** 🔔 — Pending invites with accept/decline, bell with unread
badge (polling + realtime push), notifications for invites and chat messages.
- **Team chat** 💬 — Realtime workspace channel via Socket.IO.
- **Private chats** ✉️ — 1:1 DMs with reactions, edit, attachments, search, typing,
online presence and unread badges; team chats listed per workspace.
- **Uploads** 📤 — Avatar/background images uploadable (served from `/uploads`).
- **Calendar** 🗓️ — Deadlines from tasks appear automatically.
- **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list.
- **Settings window** ⚙️ — Profile (name, email, avatar, password), appearance
(light/dark/system), language (**Deutsch, English, 中文**), background image + blur.
- **Admin dashboard** 🛡️ — Stats, user management (admin flag, delete).
- **User discovery** 🔎 — Fuzzy search to find and invite collaborators.
- **Real-time collaboration** — Socket.IO rooms per page: live content broadcast,
presence (who is viewing), typing indicators.
- **Comments** — Threaded comments per page.
+163
View File
@@ -12,10 +12,12 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0",
"socket.io": "^4.7.5"
}
@@ -66,6 +68,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -135,6 +143,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -173,6 +198,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -209,6 +249,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -447,6 +493,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-async-errors": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
"license": "ISC",
"peerDependencies": {
"express": "^4.16.2"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
@@ -636,6 +691,12 @@
"node": ">= 0.10"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -796,6 +857,27 @@
"node": ">= 0.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/morgan": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz",
@@ -822,6 +904,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1016,6 +1117,12 @@
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1073,6 +1180,27 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1362,6 +1490,29 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1384,6 +1535,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@@ -1399,6 +1556,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+2
View File
@@ -15,10 +15,12 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0",
"socket.io": "^4.7.5"
}
+194
View File
@@ -76,6 +76,192 @@ CREATE TABLE IF NOT EXISTS app_settings (
-- Global admin flag (first user created via web installer)
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- Profile / appearance / locale settings (edited via settings window)
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en';
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_image TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_blur BOOLEAN NOT NULL DEFAULT TRUE;
-- Books: independent containers that hold pages (collapsible in sidebar)
CREATE TABLE IF NOT EXISTS books (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled book' CHECK (char_length(title) BETWEEN 1 AND 200),
icon TEXT NOT NULL DEFAULT '📚',
position DOUBLE PRECISION NOT NULL DEFAULT 0,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_books_workspace ON books(workspace_id);
-- Pages can optionally live inside a book (book_id NULL = top-level page)
ALTER TABLE pages ADD COLUMN IF NOT EXISTS book_id UUID REFERENCES books(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_pages_book ON pages(book_id);
-- Tasks: can link to a book and/or a page, optional deadline (feeds the calendar)
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 300),
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')),
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high')),
due_at TIMESTAMPTZ NULL,
position DOUBLE PRECISION NOT NULL DEFAULT 0,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id);
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(workspace_id, due_at);
-- Time tracking entries (open entry = running timer, ended_at IS NULL)
CREATE TABLE IF NOT EXISTS time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
description TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ NULL,
duration_seconds INTEGER NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id);
-- Pending workspace invites (accept/decline via notification bell)
CREATE TABLE IF NOT EXISTS workspace_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin','member','viewer')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','declined')),
invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_invites_email ON workspace_invites(email);
CREATE INDEX IF NOT EXISTS idx_invites_user ON workspace_invites(user_id);
-- Notifications (invites, chat mentions/messages, etc.)
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
link TEXT NOT NULL DEFAULT '',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, is_read);
-- Workspace team chat
CREATE TABLE IF NOT EXISTS chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_chat_ws ON chat_messages(workspace_id, created_at);
-- Teams inside a workspace
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
icon TEXT NOT NULL DEFAULT '👥',
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Workspace optionally owned by a team (shows TEAM_"name" badge in UI)
ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
-- Tasks: assignee (user) + owning team (team boards)
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_id UUID REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks(team_id);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_id);
-- Glassmorphism widgets over background image
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_glass BOOLEAN NOT NULL DEFAULT TRUE;
CREATE TABLE IF NOT EXISTS team_members (
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('manager','member')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id)
);
-- Permission groups (e.g. "Team Creators" with teams.create / teams.edit / teams.invite)
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
permissions TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, name)
);
CREATE TABLE IF NOT EXISTS group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, user_id)
);
-- Per-user permission overrides (explicit allow/deny beats group rights)
CREATE TABLE IF NOT EXISTS user_permissions (
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK (permission IN ('teams.create','teams.edit','teams.invite','workspace.invite')),
allow BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, user_id, permission)
);
-- Chat attachments + edit timestamps
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment TEXT NOT NULL DEFAULT '';
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ NULL;
-- Private 1:1 conversations (DMs)
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_conv_members_user ON conversation_members(user_id);
CREATE TABLE IF NOT EXISTS dm_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
attachment TEXT NOT NULL DEFAULT '',
edited_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dm_conv ON dm_messages(conversation_id, created_at);
-- Emoji reactions on chat + DM messages (toggle per user+emoji)
CREATE TABLE IF NOT EXISTS reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_type TEXT NOT NULL CHECK (target_type IN ('chat','dm')),
target_id UUID NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (target_type, target_id, user_id, emoji)
);
CREATE INDEX IF NOT EXISTS idx_reactions_target ON reactions(target_type, target_id);
-- Ensure workspace owner is always a member with role=owner
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
BEGIN
@@ -110,3 +296,11 @@ FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
DROP TRIGGER IF EXISTS trg_pages_touch ON pages;
CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
DROP TRIGGER IF EXISTS trg_books_touch ON books;
CREATE TRIGGER trg_books_touch BEFORE UPDATE ON books
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
DROP TRIGGER IF EXISTS trg_tasks_touch ON tasks;
CREATE TRIGGER trg_tasks_touch BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
+28 -1
View File
@@ -1,4 +1,5 @@
import 'dotenv/config';
import 'express-async-errors';
import http from 'node:http';
import express from 'express';
import cors from 'cors';
@@ -11,6 +12,18 @@ import setupRoutes, { isInstalled } from './routes/setup.js';
import workspaceRoutes from './routes/workspaces.js';
import pageRoutes from './routes/pages.js';
import userRoutes from './routes/users.js';
import bookRoutes from './routes/books.js';
import taskRoutes from './routes/tasks.js';
import timeRoutes from './routes/time.js';
import adminRoutes from './routes/admin.js';
import inviteRoutes from './routes/invites.js';
import notificationRoutes from './routes/notifications.js';
import chatRoutes from './routes/chat.js';
import teamRoutes from './routes/teams.js';
import groupRoutes from './routes/groups.js';
import uploadRoutes from './routes/upload.js';
import dmRoutes from './routes/dm.js';
import reactionRoutes from './routes/reactions.js';
import { initSocket } from './socket.js';
const app = express();
@@ -43,6 +56,19 @@ app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRout
app.use('/api/workspaces', workspaceRoutes);
app.use('/api/pages', pageRoutes);
app.use('/api/users', userRoutes);
app.use('/api/books', bookRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/time', timeRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/invites', inviteRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/chat', chatRoutes);
app.use('/api/teams', teamRoutes);
app.use('/api/groups', groupRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/dm', dmRoutes);
app.use('/api/reactions', reactionRoutes);
app.use('/uploads', express.static('uploads'));
app.get('/api/health', async (_req, res) => {
try {
@@ -54,7 +80,8 @@ app.get('/api/health', async (_req, res) => {
});
app.use((err, _req, res, _next) => {
console.error('[unhandled]', err);
console.error('[unhandled]', err?.message || err);
if (err?.code === '22P02') return res.status(400).json({ error: 'Invalid ID format' });
res.status(500).json({ error: 'Internal server error' });
});
+94
View File
@@ -0,0 +1,94 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { requireAdmin } from './guards.js';
const router = Router();
router.use(requireAuth, requireAdmin);
// GET /api/admin/overview — counts + recent users
router.get('/overview', async (_req, res) => {
const [[u], [w], [b], [p], [t], [e]] = await Promise.all(
['users', 'workspaces', 'books', 'pages', 'tasks', 'time_entries'].map(
(tbl) => query(`SELECT COUNT(*)::int AS c FROM ${tbl}`).then((r) => r.rows),
),
);
const { rows: recent } = await query(
'SELECT id, name, email, is_admin, created_at FROM users ORDER BY created_at DESC LIMIT 8',
);
return res.json({
counts: {
users: u.c, workspaces: w.c, books: b.c, pages: p.c, tasks: t.c, timeEntries: e.c,
},
recentUsers: recent,
});
});
// GET /api/admin/users
router.get('/users', async (_req, res) => {
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.is_admin, u.locale, u.created_at,
(SELECT COUNT(*)::int FROM workspace_members m WHERE m.user_id = u.id) AS workspaces
FROM users u ORDER BY u.created_at`,
);
return res.json({ users: rows });
});
// POST /api/admin/users { name, email, password, is_admin? } — admin creates accounts.
// Pending workspace invites for that email are linked automatically.
router.post('/users', async (req, res) => {
const { name = '', email = '', password = '', is_admin = false } = req.body ?? {};
if (String(name).trim().length < 1 || String(name).length > 100) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' });
}
if (String(password).length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 chars' });
}
const { default: bcrypt } = await import('bcryptjs');
const hash = await bcrypt.hash(String(password), 12);
try {
const { rows } = await query(
`INSERT INTO users (email, name, password_hash, is_admin)
VALUES ($1,$2,$3,$4) RETURNING id, name, email, is_admin, created_at`,
[String(email).toLowerCase(), String(name).trim(), hash, Boolean(is_admin)],
);
const user = rows[0];
await query('UPDATE workspace_invites SET user_id = $1 WHERE email = $2 AND user_id IS NULL', [
user.id, user.email,
]);
return res.status(201).json({ user });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Email already registered' });
throw err;
}
});
// PATCH /api/admin/users/:id { is_admin }
router.patch('/users/:id', async (req, res) => {
const { is_admin } = req.body ?? {};
if (typeof is_admin !== 'boolean') return res.status(400).json({ error: 'is_admin boolean required' });
if (req.params.id === req.user.id && is_admin === false) {
return res.status(400).json({ error: 'You cannot demote yourself' });
}
const { rows } = await query(
'UPDATE users SET is_admin = $1 WHERE id = $2 RETURNING id, name, email, is_admin',
[is_admin, req.params.id],
);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
return res.json({ user: rows[0] });
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', async (req, res) => {
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'You cannot delete yourself' });
}
const { rowCount } = await query('DELETE FROM users WHERE id = $1', [req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'User not found' });
return res.status(204).end();
});
export default router;
+22 -4
View File
@@ -7,11 +7,29 @@ const router = Router();
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function publicUser(row) {
return { id: row.id, email: row.email, name: row.name, isAdmin: !!row.is_admin, createdAt: row.created_at };
return {
id: row.id,
email: row.email,
name: row.name,
isAdmin: !!row.is_admin,
avatar: row.avatar || '',
locale: row.locale || 'en',
theme: row.theme || 'system',
bgImage: row.bg_image || '',
bgBlur: row.bg_blur !== false,
bgGlass: row.bg_glass !== false,
createdAt: row.created_at,
};
}
// POST /api/auth/register { email, name, password }
// POST /api/auth/register — DISABLED after installation.
// The web installer creates the first admin; afterwards only admins
// may create users (POST /api/admin/users).
router.post('/register', async (req, res) => {
const { rows } = await query("SELECT value FROM app_settings WHERE key = 'installed'");
if (rows[0]?.value === 'true') {
return res.status(403).json({ error: 'Public registration is disabled. Ask an admin to create your account.' });
}
const { email = '', name = '', password = '' } = req.body ?? {};
if (!EMAIL_RE.test(String(email).toLowerCase())) {
return res.status(400).json({ error: 'Valid email required' });
@@ -27,7 +45,7 @@ router.post('/register', async (req, res) => {
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
const hash = await bcrypt.hash(String(password), 12);
const { rows } = await query(
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, created_at',
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at',
[String(email).toLowerCase(), String(name).trim(), hash],
);
const token = signToken(rows[0]);
@@ -57,7 +75,7 @@ router.post('/login', async (req, res) => {
// GET /api/auth/me
router.get('/me', requireAuth, async (req, res) => {
const { rows } = await query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.id]);
const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
return res.json({ user: publicUser(rows[0]) });
});
+71
View File
@@ -0,0 +1,71 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/books/by-workspace/:workspaceId
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
'SELECT * FROM books WHERE workspace_id = $1 ORDER BY position, updated_at DESC',
[req.params.workspaceId],
);
return res.json({ books: rows });
});
// POST /api/books { workspace_id, title?, icon? } (member+)
router.post('/', async (req, res) => {
const { workspace_id, title = 'Untitled book', icon = '📚' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const role = await getRole(workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot create books' });
}
const { rows } = await query(
'INSERT INTO books (workspace_id, title, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
[workspace_id, String(title).slice(0, 200) || 'Untitled book', String(icon).slice(0, 20) || '📚', req.user.id],
);
return res.status(201).json({ book: rows[0] });
});
// PATCH /api/books/:id { title?, icon?, position? } (member+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
const book = found[0];
if (!book) return res.status(404).json({ error: 'Not found' });
const role = await getRole(book.workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot edit' });
}
const { title, icon, position } = req.body ?? {};
const { rows } = await query(
`UPDATE books SET title = COALESCE($1, title), icon = COALESCE($2, icon),
position = COALESCE($3, position) WHERE id = $4 RETURNING *`,
[
title !== undefined ? String(title).slice(0, 200) : null,
icon !== undefined ? String(icon).slice(0, 20) : null,
position !== undefined ? Number(position) : null,
book.id,
],
);
return res.json({ book: rows[0] });
});
// DELETE /api/books/:id (member+; pages inside are removed via FK cascade)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
const book = found[0];
if (!book) return res.status(404).json({ error: 'Not found' });
const role = await getRole(book.workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot delete' });
}
await query('DELETE FROM books WHERE id = $1', [book.id]);
return res.status(204).end();
});
export default router;
+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;
+51 -2
View File
@@ -13,8 +13,7 @@ export async function getRole(workspaceId, userId) {
}
/** Require membership; optionally require minimum role. Attaches req.membership. */
export function requireWorkspace(minRole = 'viewer') {
return async (req, res, next) => {
export function requireWorkspace(minRole = 'viewer') { return async (req, res, next) => {
const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id;
if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
const role = await getRole(workspaceId, req.user.id);
@@ -32,3 +31,53 @@ export async function getPageWithWorkspace(pageId) {
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
return rows[0] || null;
}
/** Require global admin flag (set via web installer / admin dashboard). */
export async function requireAdmin(req, res, next) {
const { rows } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
return next();
}
export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
/** True if user holds a workspace permission.
* owner/admin always pass; explicit per-user allow/deny beats group rights. */
export async function hasPerm(workspaceId, userId, perm) {
const role = await getRole(workspaceId, userId);
if (role === 'owner' || role === 'admin') return true;
if (!role) return false;
const { rows: override } = await query(
'SELECT allow FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3',
[workspaceId, userId, perm],
);
if (override[0]) return !!override[0].allow;
const { rows } = await query(
`SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id
WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`,
[workspaceId, userId, perm],
);
return !!rows[0];
}
/** Seed default permission groups for a workspace (idempotent). */
export async function ensureDefaultGroups(workspaceId) {
await query(
`INSERT INTO groups (workspace_id, name, permissions)
VALUES ($1, 'Team Creators', $2)
ON CONFLICT (workspace_id, name) DO NOTHING`,
[workspaceId, ['teams.create', 'teams.edit', 'teams.invite']],
);
}
/** True if user manages the team (manager, workspace owner/admin, or teams.edit perm). */
export async function canManageTeam(team, userId) {
const role = await getRole(team.workspace_id, userId);
if (role === 'owner' || role === 'admin') return true;
const { rows } = await query(
'SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND role = $3',
[team.id, userId, 'manager'],
);
if (rows[0]) return true;
return hasPerm(team.workspace_id, userId, 'teams.edit');
}
+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;
+20 -6
View File
@@ -30,24 +30,31 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
return res.json({ pages: rows, role });
});
// POST /api/pages { workspace_id, parent_id?, title? }
// POST /api/pages { workspace_id, parent_id?, book_id?, title? }
router.post('/', async (req, res) => {
const { workspace_id, parent_id = null, title = 'Untitled' } = req.body ?? {};
const { workspace_id, parent_id = null, book_id = null, title = 'Untitled' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const role = await getRole(workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot create pages' });
}
if (book_id) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspace_id]);
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
}
if (parent_id) {
const parent = await getPageWithWorkspace(parent_id);
if (!parent || parent.workspace_id !== workspace_id) {
return res.status(400).json({ error: 'Invalid parent_id' });
}
if ((parent.book_id || null) !== (book_id || null)) {
return res.status(400).json({ error: 'Parent lives in a different book' });
}
}
const { rows } = await query(
`INSERT INTO pages (workspace_id, parent_id, title, created_by)
VALUES ($1,$2,$3,$4) RETURNING *`,
[workspace_id, parent_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
`INSERT INTO pages (workspace_id, parent_id, book_id, title, created_by)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[workspace_id, parent_id, book_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
);
return res.status(201).json({ page: rows[0] });
});
@@ -71,7 +78,7 @@ router.patch('/:id', async (req, res) => {
const page = await getPageWithWorkspace(req.params.id);
if (!page) return res.status(404).json({ error: 'Not found' });
if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' });
const { title, icon, cover, content_json, content_html, position, parent_id, is_archived } = req.body ?? {};
const { title, icon, cover, content_json, content_html, position, parent_id, book_id, is_archived } = req.body ?? {};
if (parent_id !== undefined && parent_id !== null) {
if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' });
const parent = await getPageWithWorkspace(parent_id);
@@ -79,6 +86,10 @@ router.patch('/:id', async (req, res) => {
return res.status(400).json({ error: 'Invalid parent_id' });
}
}
if (book_id !== undefined && book_id !== null) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, page.workspace_id]);
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
}
const { rows } = await query(
`UPDATE pages SET
title = COALESCE($1, title),
@@ -88,6 +99,7 @@ router.patch('/:id', async (req, res) => {
content_html = COALESCE($5, content_html),
position = COALESCE($6, position),
parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END,
book_id = CASE WHEN $12 THEN $11 ELSE book_id END,
is_archived = COALESCE($9, is_archived)
WHERE id = $10 RETURNING *`,
[
@@ -101,6 +113,8 @@ router.patch('/:id', async (req, res) => {
parent_id !== undefined,
is_archived !== undefined ? Boolean(is_archived) : null,
page.id,
book_id !== undefined ? book_id : null,
book_id !== undefined,
],
);
const updated = rows[0];
+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;
+152
View File
@@ -0,0 +1,152 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
const STATUSES = ['todo', 'doing', 'done'];
const PRIORITIES = ['low', 'normal', 'high'];
async function canWrite(userId, workspaceId) {
const role = await getRole(workspaceId, userId);
return role === 'owner' || role === 'admin' || role === 'member';
}
async function checkLinks(workspaceId, book_id, page_id, team_id, assignee_id) {
if (book_id) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspaceId]);
if (!rows[0]) return 'Invalid book_id for this workspace';
}
if (page_id) {
const { rows } = await query('SELECT id FROM pages WHERE id = $1 AND workspace_id = $2', [page_id, workspaceId]);
if (!rows[0]) return 'Invalid page_id for this workspace';
}
if (team_id) {
const { rows } = await query('SELECT id FROM teams WHERE id = $1 AND workspace_id = $2', [team_id, workspaceId]);
if (!rows[0]) return 'Invalid team_id for this workspace';
}
if (assignee_id) {
const { rows } = await query(
'SELECT 1 FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
[workspaceId, assignee_id],
);
if (!rows[0]) return 'Assignee must be a workspace member';
}
return null;
}
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=&team_id=&assignee=
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { status, from, to, team_id, assignee } = req.query;
const conds = ['t.workspace_id = $1'];
const params = [req.params.workspaceId];
if (status && STATUSES.includes(String(status))) {
params.push(String(status));
conds.push(`t.status = $${params.length}`);
}
if (from) {
params.push(new Date(String(from)));
conds.push(`t.due_at >= $${params.length}`);
}
if (to) {
params.push(new Date(String(to)));
conds.push(`t.due_at <= $${params.length}`);
}
if (team_id) {
params.push(String(team_id));
conds.push(`t.team_id = $${params.length}`);
}
if (assignee) {
params.push(String(assignee));
conds.push(`t.assignee_id = $${params.length}`);
}
const { rows } = await query(
`SELECT t.*, b.title AS book_title, p.title AS page_title,
tm.name AS team_name, u.name AS assignee_name, u.avatar AS assignee_avatar
FROM tasks t LEFT JOIN books b ON b.id = t.book_id LEFT JOIN pages p ON p.id = t.page_id
LEFT JOIN teams tm ON tm.id = t.team_id LEFT JOIN users u ON u.id = t.assignee_id
WHERE ${conds.join(' AND ')} ORDER BY t.due_at NULLS LAST, t.position, t.created_at`,
params,
);
return res.json({ tasks: rows });
});
// POST /api/tasks { workspace_id, book_id?, page_id?, team_id?, assignee_id?, title, ... }
router.post('/', async (req, res) => {
const { workspace_id, book_id = null, page_id = null, team_id = null, assignee_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await canWrite(req.user.id, workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot create tasks' });
}
if (String(title).trim().length < 1 || String(title).length > 300) {
return res.status(400).json({ error: 'Title must be 1-300 chars' });
}
if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
const linkErr = await checkLinks(workspace_id, book_id, page_id, team_id, assignee_id);
if (linkErr) return res.status(400).json({ error: linkErr });
const { rows } = await query(
`INSERT INTO tasks (workspace_id, book_id, page_id, team_id, assignee_id, title, status, priority, due_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[workspace_id, book_id, page_id, team_id, assignee_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id],
);
return res.status(201).json({ task: rows[0] });
});
// PATCH /api/tasks/:id
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
const task = found[0];
if (!task) return res.status(404).json({ error: 'Not found' });
if (!(await canWrite(req.user.id, task.workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot edit' });
}
const { title, status, priority, due_at, book_id, page_id, team_id, assignee_id, position } = req.body ?? {};
if (status !== undefined && !STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (priority !== undefined && !PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
if (title !== undefined && (String(title).trim().length < 1 || String(title).length > 300)) {
return res.status(400).json({ error: 'Title must be 1-300 chars' });
}
const nextBook = book_id !== undefined ? book_id : task.book_id;
const nextPage = page_id !== undefined ? page_id : task.page_id;
const nextTeam = team_id !== undefined ? team_id : task.team_id;
const nextAssignee = assignee_id !== undefined ? assignee_id : task.assignee_id;
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage, nextTeam, nextAssignee);
if (linkErr) return res.status(400).json({ error: linkErr });
const sets = [];
const params = [];
const push = (col, val) => { params.push(val); sets.push(`${col} = $${params.length}`); };
if (title !== undefined) push('title', String(title).trim());
if (status !== undefined) push('status', status);
if (priority !== undefined) push('priority', priority);
if (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null);
if (book_id !== undefined) push('book_id', book_id);
if (page_id !== undefined) push('page_id', page_id);
if (team_id !== undefined) push('team_id', team_id);
if (assignee_id !== undefined) push('assignee_id', assignee_id);
if (position !== undefined) push('position', Number(position));
if (sets.length === 0) return res.json({ task });
params.push(task.id);
const { rows } = await query(
`UPDATE tasks SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
params,
);
return res.json({ task: rows[0] });
});
// DELETE /api/tasks/:id
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
const task = found[0];
if (!task) return res.status(404).json({ error: 'Not found' });
if (!(await canWrite(req.user.id, task.workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot delete' });
}
await query('DELETE FROM tasks WHERE id = $1', [task.id]);
return res.status(204).end();
});
export default router;
+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;
+116
View File
@@ -0,0 +1,116 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
function serialize(row) {
if (!row) return row;
return row;
}
// GET /api/time/by-workspace/:workspaceId — entries + currently running timer
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const mine = req.query.mine === '1';
const params = [req.params.workspaceId];
let cond = 'e.workspace_id = $1';
if (mine) {
params.push(req.user.id);
cond += ` AND e.user_id = $${params.length}`;
}
const { rows } = await query(
`SELECT e.*, b.title AS book_title, p.title AS page_title, u.name AS user_name
FROM time_entries e
LEFT JOIN books b ON b.id = e.book_id
LEFT JOIN pages p ON p.id = e.page_id
LEFT JOIN users u ON u.id = e.user_id
WHERE ${cond} ORDER BY e.started_at DESC LIMIT 200`,
params,
);
const open = rows.find((r) => r.user_id === req.user.id && !r.ended_at) || null;
return res.json({ entries: rows, open });
});
// POST /api/time/start { workspace_id, book_id?, page_id?, description? }
router.post('/start', async (req, res) => {
const { workspace_id, book_id = null, page_id = null, description = '' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const role = await getRole(workspace_id, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows: running } = await query(
'SELECT id FROM time_entries WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL',
[workspace_id, req.user.id],
);
if (running[0]) return res.status(409).json({ error: 'Timer already running', entry: running[0] });
const { rows } = await query(
`INSERT INTO time_entries (workspace_id, user_id, book_id, page_id, description)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[workspace_id, req.user.id, book_id, page_id, String(description).slice(0, 500)],
);
return res.status(201).json({ entry: serialize(rows[0]) });
});
// POST /api/time/stop { workspace_id } — stops my running timer
router.post('/stop', async (req, res) => {
const { workspace_id } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const { rows } = await query(
`UPDATE time_entries SET ended_at = now(),
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
WHERE id = (
SELECT id FROM time_entries
WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1
) RETURNING *`,
[workspace_id, req.user.id],
);
if (!rows[0]) return res.status(404).json({ error: 'No running timer' });
return res.json({ entry: rows[0] });
});
// PATCH /api/time/:id { description?, book_id?, page_id? } (own or admin+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
const entry = found[0];
if (!entry) return res.status(404).json({ error: 'Not found' });
const role = await getRole(entry.workspace_id, req.user.id);
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const { description, book_id, page_id } = req.body ?? {};
const { rows } = await query(
`UPDATE time_entries SET description = COALESCE($1, description),
book_id = CASE WHEN $4 THEN $2 ELSE book_id END,
page_id = CASE WHEN $5 THEN $3 ELSE page_id END
WHERE id = $6 RETURNING *`,
[
description !== undefined ? String(description).slice(0, 500) : null,
book_id !== undefined ? book_id : null,
page_id !== undefined ? page_id : null,
book_id !== undefined, page_id !== undefined,
entry.id,
],
);
return res.json({ entry: rows[0] });
});
// DELETE /api/time/:id (own or admin+)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
const entry = found[0];
if (!entry) return res.status(404).json({ error: 'Not found' });
const role = await getRole(entry.workspace_id, req.user.id);
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await query('DELETE FROM time_entries WHERE id = $1', [entry.id]);
return res.status(204).end();
});
export default router;
+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;
+100 -4
View File
@@ -1,18 +1,114 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
// GET /api/users/search?email= — find user by email (for invites)
const LOCALES = ['de', 'en', 'zh'];
const THEMES = ['light', 'dark', 'system'];
// GET /api/users/search?email= — find user by exact email (for invites)
router.get('/search', async (req, res) => {
const { email = '' } = req.query;
if (!email) return res.status(400).json({ error: 'email required' });
const { rows } = await query('SELECT id, email, name FROM users WHERE email = $1', [
String(email).toLowerCase(),
]);
const { rows } = await query(
'SELECT id, email, name, avatar FROM users WHERE email = $1',
[String(email).toLowerCase()],
);
return res.json({ user: rows[0] || null });
});
// GET /api/users/discover?q= — fuzzy user discovery for invites/mentions.
// Returns id, name, email, avatar (never password hashes).
router.get('/discover', async (req, res) => {
const q = String(req.query.q || '').trim().slice(0, 60);
if (q.length < 2) return res.json({ users: [] });
const { rows } = await query(
`SELECT id, name, email, avatar FROM users
WHERE name ILIKE $1 OR email ILIKE $1
ORDER BY name LIMIT 10`,
[`%${q}%`],
);
return res.json({ users: rows });
});
// PATCH /api/users/me — profile + appearance settings
router.patch('/me', async (req, res) => {
const { name, email, avatar, locale, theme, bg_image, bg_blur, bg_glass } = req.body ?? {};
if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
if (email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email))) {
return res.status(400).json({ error: 'Valid email required' });
}
if (locale !== undefined && !LOCALES.includes(locale)) {
return res.status(400).json({ error: 'Locale must be de, en or zh' });
}
if (theme !== undefined && !THEMES.includes(theme)) {
return res.status(400).json({ error: 'Theme must be light, dark or system' });
}
if (avatar !== undefined && String(avatar).length > 500) {
return res.status(400).json({ error: 'Avatar too long (max 500 chars, emoji or URL)' });
}
if (bg_image !== undefined && String(bg_image).length > 2000) {
return res.status(400).json({ error: 'Background URL too long' });
}
try {
const { rows } = await query(
`UPDATE users SET
name = COALESCE($1, name),
email = COALESCE($2, email),
avatar = COALESCE($3, avatar),
locale = COALESCE($4, locale),
theme = COALESCE($5, theme),
bg_image = COALESCE($6, bg_image),
bg_blur = COALESCE($7, bg_blur),
bg_glass = COALESCE($8, bg_glass)
WHERE id = $9
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at`,
[
name !== undefined ? String(name).trim() : null,
email !== undefined ? String(email).toLowerCase() : null,
avatar !== undefined ? String(avatar).slice(0, 500) : null,
locale !== undefined ? locale : null,
theme !== undefined ? theme : null,
bg_image !== undefined ? String(bg_image).slice(0, 2000) : null,
bg_blur !== undefined ? Boolean(bg_blur) : null,
bg_glass !== undefined ? Boolean(bg_glass) : null,
req.user.id,
],
);
const u = rows[0];
return res.json({
user: {
id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin,
avatar: u.avatar || '', locale: u.locale, theme: u.theme,
bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false,
bgGlass: u.bg_glass !== false, createdAt: u.created_at,
},
});
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Email already taken' });
console.error('[users/me]', err.message);
return res.status(500).json({ error: 'Update failed' });
}
});
// PATCH /api/users/me/password { currentPassword, newPassword }
router.patch('/me/password', async (req, res) => {
const { currentPassword = '', newPassword = '' } = req.body ?? {};
if (String(newPassword).length < 8) {
return res.status(400).json({ error: 'New password must be at least 8 chars' });
}
const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
const ok = await bcrypt.compare(String(currentPassword), rows[0].password_hash);
if (!ok) return res.status(401).json({ error: 'Current password is wrong' });
const hash = await bcrypt.hash(String(newPassword), 12);
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.id]);
return res.json({ ok: true });
});
export default router;
+82 -13
View File
@@ -1,16 +1,18 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole, requireWorkspace, ROLES } from './guards.js';
import { getRole, requireWorkspace, hasPerm, ensureDefaultGroups, PERMS, ROLES } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/workspaces — list my workspaces
// GET /api/workspaces — list my workspaces (with team badge info)
router.get('/', async (req, res) => {
const { rows } = await query(
`SELECT w.id, w.name, w.owner_id, w.created_at, w.updated_at, m.role
`SELECT w.id, w.name, w.owner_id, w.team_id, w.created_at, w.updated_at, m.role,
t.name AS team_name
FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id
LEFT JOIN teams t ON t.id = w.team_id
WHERE m.user_id = $1 ORDER BY w.updated_at DESC`,
[req.user.id],
);
@@ -27,6 +29,7 @@ router.post('/', async (req, res) => {
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
[String(name).trim(), req.user.id],
);
await ensureDefaultGroups(rows[0].id);
return res.status(201).json({ workspace: rows[0] });
});
@@ -59,7 +62,7 @@ router.delete('/:id', requireWorkspace('owner'), async (req, res) => {
// GET /api/workspaces/:id/members
router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
const { rows } = await query(
`SELECT u.id, u.email, u.name, m.role, m.created_at
`SELECT u.id, u.email, u.name, u.avatar, m.role, m.created_at
FROM workspace_members m JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at`,
[req.params.id],
@@ -67,20 +70,86 @@ router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
return res.json({ members: rows });
});
// POST /api/workspaces/:id/members { email, role } (admin+; cannot grant owner)
router.post('/:id/members', requireWorkspace('admin'), async (req, res) => {
// POST /api/workspaces/:id/members { email, role } — creates a pending invite
// + notification (admin+ or workspace.invite permission; cannot grant owner)
router.post('/:id/members', requireWorkspace('viewer'), async (req, res) => {
const wsId = req.params.id;
const myRole = req.membership.role;
const allowed = myRole === 'owner' || myRole === 'admin' || (await hasPerm(wsId, req.user.id, 'workspace.invite'));
if (!allowed) return res.status(403).json({ error: 'No invite permission' });
const { email = '', role = 'member' } = req.body ?? {};
if (!ROLES.includes(role) || role === 'owner') {
return res.status(400).json({ error: 'Role must be admin, member or viewer' });
}
const { rows: users } = await query('SELECT id FROM users WHERE email = $1', [String(email).toLowerCase()]);
if (!users[0]) return res.status(404).json({ error: 'No user with that email (they must register first)' });
await query(
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[req.params.id, users[0].id, role],
const targetEmail = String(email).toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(targetEmail)) {
return res.status(400).json({ error: 'Valid email required' });
}
const { rows: users } = await query('SELECT id, name FROM users WHERE email = $1', [targetEmail]);
const target = users[0] || null;
if (target) {
const existing = await getRole(wsId, target.id);
if (existing) return res.status(409).json({ error: 'User is already a member' });
const dup = await query(
`SELECT id FROM workspace_invites WHERE workspace_id = $1 AND user_id = $2 AND status = 'pending'`,
[wsId, target.id],
);
return res.status(201).json({ ok: true });
if (dup.rows[0]) return res.status(409).json({ error: 'Invite already pending' });
}
const { rows: wsRows } = await query('SELECT name FROM workspaces WHERE id = $1', [wsId]);
const { rows: inv } = await query(
`INSERT INTO workspace_invites (workspace_id, email, user_id, role, invited_by)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[wsId, targetEmail, target?.id || null, role, req.user.id],
);
if (target) {
await query(
`INSERT INTO notifications (user_id, type, title, body, link)
VALUES ($1,'invite',$2,$3,$4)`,
[target.id, `Workspace invite: ${wsRows[0]?.name || ''}`,
`You were invited as ${role}. Accept it from the bell menu.`, `/api/invites/${inv[0].id}`],
);
req.app.get('io')?.to(`user:${target.id}`).emit('notification:new', { type: 'invite' });
}
return res.status(201).json({ invite: inv[0] });
});
// GET /api/workspaces/:id/permissions — members with explicit overrides (admin+)
router.get('/:id/permissions', requireWorkspace('admin'), async (req, res) => {
const { rows } = await query(
`SELECT m.user_id, u.name, u.email, m.role,
(SELECT json_object_agg(permission, allow) FROM user_permissions
WHERE workspace_id = $1 AND user_id = m.user_id) AS overrides
FROM workspace_members m JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY u.name`,
[req.params.id],
);
return res.json({ members: rows });
});
// PATCH /api/workspaces/:id/permissions { user_id, permission, mode }
// mode: 'allow' | 'deny' | 'inherit' (inherit removes the override) (admin+)
router.patch('/:id/permissions', requireWorkspace('admin'), async (req, res) => {
const { user_id, permission, mode } = req.body ?? {};
if (!PERMS.includes(permission)) return res.status(400).json({ error: 'Invalid permission' });
if (!['allow', 'deny', 'inherit'].includes(mode)) return res.status(400).json({ error: 'Invalid mode' });
if (!(await getRole(req.params.id, user_id))) {
return res.status(400).json({ error: 'User must be a workspace member' });
}
if (mode === 'inherit') {
await query(
'DELETE FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3',
[req.params.id, user_id, permission],
);
} else {
await query(
`INSERT INTO user_permissions (workspace_id, user_id, permission, allow)
VALUES ($1,$2,$3,$4)
ON CONFLICT (workspace_id, user_id, permission) DO UPDATE SET allow = EXCLUDED.allow`,
[req.params.id, user_id, permission, mode === 'allow'],
);
}
return res.json({ ok: true });
});
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
+46
View File
@@ -30,6 +30,7 @@ export function initSocket(httpServer, corsOrigin) {
});
const presence = new Map(); // room -> Map(socketId -> { userId, email, name })
const online = new Map(); // userId -> connection count (global presence)
function emitPresence(room) {
const members = [...(presence.get(room)?.values() ?? [])];
@@ -38,6 +39,25 @@ export function initSocket(httpServer, corsOrigin) {
io.on('connection', (socket) => {
const user = socket.data.user;
// Personal room for notifications (bell).
socket.join(`user:${user.id}`);
// Global online presence.
online.set(user.id, (online.get(user.id) || 0) + 1);
io.emit('presence:online', [...online.keys()]);
socket.on('chat:join', async ({ workspaceId }) => {
try {
if (!workspaceId) return;
const { rows } = await query(
'SELECT 1 FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
[workspaceId, user.id],
);
if (!rows[0]) return socket.emit('error:message', { error: 'Forbidden' });
socket.join(`chat:${workspaceId}`);
} catch {
socket.emit('error:message', { error: 'Join failed' });
}
});
socket.on('page:join', async ({ pageId }) => {
try {
@@ -94,10 +114,36 @@ export function initSocket(httpServer, corsOrigin) {
socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name });
});
socket.on('dm:join', async ({ conversationId }) => {
try {
if (!conversationId) return;
const { rows } = await query(
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
[conversationId, user.id],
);
if (!rows[0]) return socket.emit('error:message', { error: 'Forbidden' });
socket.join(`dm:${conversationId}`);
} catch {
socket.emit('error:message', { error: 'Join failed' });
}
});
socket.on('chat:typing', ({ workspaceId, name }) => {
socket.to(`chat:${workspaceId}`).emit('chat:typing', { userId: user.id, name });
});
socket.on('dm:typing', ({ conversationId, name }) => {
socket.to(`dm:${conversationId}`).emit('dm:typing', { userId: user.id, name });
});
socket.on('disconnect', () => {
for (const [room, members] of presence) {
if (members.delete(socket.id)) emitPresence(room);
}
const left = (online.get(user.id) || 1) - 1;
if (left <= 0) online.delete(user.id);
else online.set(user.id, left);
io.emit('presence:online', [...online.keys()]);
});
});
+52
View File
@@ -59,6 +59,58 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML).
## Users
- `GET /users/search?email=` — lookup for invites (auth required).
- `GET /users/discover?q=` — fuzzy discovery (min 2 chars, max 10 hits, no hashes).
- `PATCH /users/me` — profile/appearance (`name, email, avatar, locale, theme, bg_image, bg_blur`).
- `PATCH /users/me/password` — `{ currentPassword, newPassword }`.
## Books
- `GET /books/by-workspace/:workspaceId`, `POST /books {workspace_id,title?,icon?}`
- `PATCH /books/:id`, `DELETE /books/:id` (pages inside cascade).
## Tasks
- `GET /tasks/by-workspace/:workspaceId?status=&from=&to=` (calendar range on `due_at`)
- `POST /tasks {workspace_id, book_id?, page_id?, title, status?, priority?, due_at?}`
- `PATCH /tasks/:id`, `DELETE /tasks/:id`
## Time tracking
- `GET /time/by-workspace/:workspaceId` → `{ entries, open }`
- `POST /time/start {workspace_id, book_id?, page_id?, description?}` (409 if running)
- `POST /time/stop {workspace_id}` → sets `ended_at` + `duration_seconds`
- `PATCH /time/:id`, `DELETE /time/:id` (own or admin+)
## Admin (requires `is_admin`)
- `GET /admin/overview` → counts + recent users
- `GET /admin/users`, `POST /admin/users {name,email,password,is_admin?}` (admins create
accounts; public registration is disabled once installed), `PATCH /admin/users/:id`,
`DELETE /admin/users/:id`
## Invites & notifications
- `POST /workspaces/:id/members {email,role}` → pending invite + notification
(workspace admin+ or `workspace.invite` permission)
- `GET /invites/mine`, `POST /invites/:id/accept`, `POST /invites/:id/decline`
- `GET /workspaces/:id/permissions`, `PATCH /workspaces/:id/permissions`
(per-user allow/deny/inherit overrides, workspace admin+)
- `GET /notifications/mine?unread=1` → `{ notifications, unread }`
- `PATCH /notifications/:id/read`, `POST /notifications/read-all`
## Chat / teams / groups / upload
- `GET /chat/:workspaceId`, `POST /chat/:workspaceId {body,attachment?}`,
`PATCH /chat/message/:id`, `DELETE /chat/message/:id`, `GET /chat/:workspaceId/search?q=`
- `GET /dm`, `POST /dm {user_id}`, `GET /dm/:id/messages`, `POST /dm/:id/messages`,
`PATCH/DELETE /dm/message/:mid`, `GET /dm/:id/search?q=`
- `POST /reactions/toggle {target_type,target_id,emoji}` (chat + DM messages)
- Socket: `chat:join`, `dm:join`, `chat:typing`, `dm:typing`, `presence:online`,
`chat:message/edited/deleted`, `dm:message/edited/deleted`, `reaction:updated`
- `GET /teams/by-workspace/:id`, `POST /teams`, `PATCH/DELETE /teams/:id`,
team members CRUD, `POST /teams/:id/workspace {name}` (team workspace + badge)
- `GET /groups/by-workspace/:id` (seeds `Team Creators`), groups CRUD + members
- `POST /upload` (multipart image ≤5MB) → `{ url: /uploads/... }`
## Health
+86
View File
@@ -9,6 +9,12 @@
"version": "0.1.0",
"dependencies": {
"@tiptap/core": "^2.4.0",
"@tiptap/extension-table": "^2.4.0",
"@tiptap/extension-table-cell": "^2.4.0",
"@tiptap/extension-table-header": "^2.4.0",
"@tiptap/extension-table-row": "^2.4.0",
"@tiptap/extension-task-item": "^2.4.0",
"@tiptap/extension-task-list": "^2.4.0",
"@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",
@@ -1128,6 +1134,86 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.3.tgz",
"integrity": "sha512-xBDaT/ixkmrHKuCUlJNbzRolxjf0fFkHxybyeqfcGNDbVwOb6pOGZly6mGjBG1TXrx3U37y0jmFpwW6WU1zokA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.3.tgz",
"integrity": "sha512-f697x9RDiva8EFZvLBxEp6Nx6sY5KRbHdNMhbCnqC1PrkRKNCTOib9ddWpqIERzs3HhmqZWL7XCPzvE/t8C+IQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.3.tgz",
"integrity": "sha512-x//GuYJlTzM3GavsDxBRDjGHMY9bbnmglQ0EAPVwQM5QHXpjwKzZN7wvDMM/RwxmxzYWd6Z+Fp7Zk8RYckpzhg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.3.tgz",
"integrity": "sha512-0ekXiw6aAESlcrKFmI90ar/j7XJmfdxeJEDeGZuMwOAQs+qq5RAB7ug5WPCZjG0Kqa+Kyxzb1jMw9pSkuTYp+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-task-item": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.3.tgz",
"integrity": "sha512-7ES+qMoMgjb2iXI8iRzgegMeM1pE4zQAekT/5lQyuTv6oEyZw5s3TUZWeTYCq5TI/MezYGu3X2sV5PQnfBAsdA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-task-list": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.3.tgz",
"integrity": "sha512-T8Y3S95ZzYlh82KGd0H81QOsJZVWweUVps6l7wkiOReNsJxhkb4qW/smNE0TeNhvbU307emiPL0CH5e+eHxjEg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.3.tgz",
+6
View File
@@ -10,6 +10,12 @@
},
"dependencies": {
"@tiptap/core": "^2.4.0",
"@tiptap/extension-table": "^2.4.0",
"@tiptap/extension-table-cell": "^2.4.0",
"@tiptap/extension-table-header": "^2.4.0",
"@tiptap/extension-table-row": "^2.4.0",
"@tiptap/extension-task-item": "^2.4.0",
"@tiptap/extension-task-list": "^2.4.0",
"@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",
+41 -6
View File
@@ -1,17 +1,52 @@
<script setup>
import { onMounted } from 'vue';
import { computed, onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import { useThemeStore } from './stores/theme';
import ThemeToggle from './components/ThemeToggle.vue';
import { useLocaleStore } from './stores/locale';
import { useUiStore } from './stores/ui';
import { fileUrl } from './files';
import { connectSocket } from './socket';
import SettingsWindow from './components/SettingsWindow.vue';
import TimeTrackerButton from './components/TimeTrackerButton.vue';
import Bell from './components/Bell.vue';
import Icon from './components/Icon.vue';
const auth = useAuthStore();
const theme = useThemeStore();
onMounted(() => theme.apply());
const locale = useLocaleStore();
const ui = useUiStore();
const bgRaw = computed(() => auth.user?.bgImage || '');
const bgIsImage = computed(() => /^(https?:\/\/|\/uploads|data:)/.test(bgRaw.value));
const bgStyle = computed(() => {
if (!bgRaw.value) return {};
const style = {
backgroundImage: bgIsImage.value ? `url("${fileUrl(bgRaw.value)}")` : bgRaw.value,
backgroundSize: 'cover',
backgroundPosition: 'center',
};
if (auth.user?.bgBlur !== false) style.filter = 'blur(16px) brightness(0.9)';
return style;
});
const glassOn = computed(() => !!bgRaw.value && auth.user?.bgGlass !== false);
onMounted(() => {
locale.set(locale.locale);
theme.apply();
auth.applyPrefs();
if (auth.isLoggedIn) connectSocket();
});
</script>
<template>
<div>
<div style="position: fixed; top: 12px; right: 12px; z-index: 50">
<ThemeToggle />
<div :class="{ 'has-bg': !!bgRaw, glass: glassOn }">
<div v-if="bgRaw" class="bg-layer" :style="bgStyle"></div>
<div v-if="auth.isLoggedIn" class="top-right">
<Bell />
<TimeTrackerButton />
<button class="ghost icon-btn" @click="ui.openSettings()" title="Settings"><Icon name="settings" /></button>
</div>
<router-view />
<SettingsWindow />
</div>
</template>
+334 -60
View File
@@ -1,93 +1,367 @@
:root {
--bg: #ffffff;
--bg-soft: #f7f7f5;
--sidebar: #fbfbfa;
--text: #111111;
--muted: #6b6b6b;
--border: #e6e6e2;
--accent: #2383e2;
--accent-soft: #e8f3fd;
--card: #ffffff;
--input: #ffffff;
--bg: #f6f4ef;
--bg-soft: #efece4;
--sidebar: linear-gradient(180deg, #fdfcf9 0%, #f3f0e8 100%);
--text: #1c1a15;
--muted: #8a8474;
--border: #e3ddcf;
--accent: #7c5cf0;
--accent-2: #00c2a8;
--accent-soft: #ece5fd;
--card: #fffdf8;
--input: #fffdf8;
--shadow: 0 6px 24px rgba(80, 60, 140, 0.1);
--radius: 14px;
}
[data-theme='dark'] {
--bg: #191919;
--bg-soft: #202020;
--sidebar: #202020;
--text: #e8e8e8;
--muted: #9b9b9b;
--border: #2e2e2e;
--accent: #4fa3e3;
--accent-soft: #1e3a52;
--card: #202020;
--input: #262626;
--bg: #14131a;
--bg-soft: #1d1c26;
--sidebar: linear-gradient(180deg, #1d1c26 0%, #16151d 100%);
--text: #f0ede6;
--muted: #9a94a8;
--border: #2d2b3a;
--accent: #a78bfa;
--accent-2: #2dd4bf;
--accent-soft: #2c2547;
--card: #1e1d28;
--input: #262534;
--shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
transition: background 0.25s, color 0.25s;
}
.bg-layer {
position: fixed; inset: -20px; z-index: -1;
background-size: cover; background-position: center;
}
a { color: var(--accent); }
h2, h3, h4 { letter-spacing: 0.2px; }
button {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-size: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff; border: none; border-radius: 10px;
padding: 8px 14px; cursor: pointer; font-size: 14px; font-weight: 600;
box-shadow: var(--shadow);
}
button.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
background: transparent; color: var(--text);
border: 1px solid var(--border); box-shadow: none;
}
button.mini { padding: 2px 8px; font-size: 13px; border-radius: 8px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.icon-btn { font-size: 18px; padding: 4px 8px; }
input, select, textarea {
background: var(--input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
width: 100%;
background: var(--input); color: var(--text);
border: 1px solid var(--border); border-radius: 10px;
padding: 8px 10px; font-size: 14px; width: 100%;
}
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 280px;
background: var(--sidebar);
width: 292px; min-width: 292px; background: var(--sidebar);
border-right: 1px solid var(--border);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px; display: flex; flex-direction: column; gap: 10px;
max-height: 100vh; overflow-y: auto; position: sticky; top: 0;
}
.main { flex: 1; padding: 24px; max-width: 960px; }
.main { flex: 1; padding: 28px; max-width: 1060px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow);
}
.auth-wrap { max-width: 380px; margin: 10vh auto; }
.auth-wrap { max-width: 400px; margin: 10vh auto; }
.auth-wrap h1 { margin-bottom: 4px; }
.muted { color: var(--muted); font-size: 13px; }
.row { display: flex; gap: 8px; align-items: center; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.tree-item { padding: 4px 8px; border-radius: 6px; cursor: pointer; }
.row.wrap { flex-wrap: wrap; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 10px; }
.topbar.wrap { flex-wrap: wrap; }
.top-right { position: fixed; top: 12px; right: 12px; z-index: 60; display: flex; gap: 8px; align-items: flex-start; }
/* Greeting */
.greeting {
display: flex; justify-content: space-between; align-items: center; gap: 12px;
background: linear-gradient(135deg, var(--accent-soft), transparent);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 20px; margin-bottom: 16px; box-shadow: var(--shadow);
}
.clock {
font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums;
background: var(--card); border: 1px solid var(--border);
padding: 6px 14px; border-radius: 12px; white-space: nowrap;
}
/* Sidebar bits */
.user-chip {
display: flex; align-items: center; gap: 8px;
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 8px 10px;
}
.side-nav { display: flex; flex-direction: column; gap: 2px; }
.side-nav a {
text-decoration: none; color: var(--text); font-weight: 600;
padding: 7px 10px; border-radius: 10px;
}
.side-nav a:hover { background: var(--bg-soft); }
.side-nav a.router-link-exact-active { background: var(--accent-soft); }
.side-label { font-size: 12px; font-weight: 800; text-transform: uppercase; color: var(--muted); margin-top: 6px; }
.book { margin-bottom: 2px; }
.book-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; }
.book-pages { margin-left: 8px; border-left: 2px solid var(--border); padding-left: 4px; }
.side-footer { margin-top: auto; display: flex; justify-content: space-between; align-items: center; padding-top: 10px; }
.tree-item {
padding: 5px 8px; border-radius: 8px; cursor: pointer;
display: flex; justify-content: space-between; align-items: center; gap: 4px;
}
.tree-item:hover { background: var(--bg-soft); }
.tree-item.active { background: var(--accent-soft); }
.presence { display: flex; gap: 6px; flex-wrap: wrap; }
.avatar {
width: 26px; height: 26px; border-radius: 50%;
background: var(--accent); color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
.search-results { margin-top: -4px; }
.pill {
background: var(--accent-soft); border: 1px solid var(--border);
border-radius: 20px; padding: 2px 10px; font-size: 12px; font-weight: 700;
}
.tiptap { min-height: 300px; border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.member-row { padding: 6px 0; border-top: 1px solid var(--border); }
.presence { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.avatar {
border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff; display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; overflow: hidden; flex-shrink: 0;
}
.link { color: var(--accent); cursor: pointer; }
/* Settings window 🪟 */
.settings-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.settings-window {
width: 640px; max-width: 100%; max-height: 88vh; overflow: hidden;
background: var(--card); border: 1px solid var(--border);
border-radius: 18px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
display: flex; flex-direction: column;
}
.settings-titlebar {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
background: var(--bg-soft);
}
.settings-titlebar strong { flex: 1; }
.traffic { display: flex; gap: 6px; }
.dot { width: 12px; height: 12px; border-radius: 50%; cursor: pointer; }
.dot.red { background: #ff5f57; } .dot.yellow { background: #febc2e; } .dot.green { background: #28c840; }
.settings-body { display: flex; min-height: 0; }
.settings-tabs {
display: flex; flex-direction: column; gap: 4px; min-width: 150px;
padding: 14px 10px; border-right: 1px solid var(--border);
}
.settings-tabs button { background: transparent; color: var(--text); box-shadow: none; text-align: left; }
.settings-tabs button.active { background: var(--accent-soft); }
.settings-content { flex: 1; padding: 18px; overflow-y: auto; }
.settings-content label { font-size: 13px; font-weight: 700; }
.emoji-row { display: flex; gap: 4px; flex-wrap: wrap; font-size: 22px; }
.emoji-row span { cursor: pointer; padding: 2px; border-radius: 8px; }
.emoji-row span:hover { background: var(--accent-soft); }
.preset-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.preset {
height: 52px; border-radius: 10px; cursor: pointer;
border: 2px solid var(--border); background: var(--bg-soft);
display: flex; align-items: center; justify-content: center; font-size: 20px;
}
.preset.active { border-color: var(--accent); }
/* Discovery */
.discover { position: relative; }
.discover-results {
position: absolute; z-index: 20; width: 100%; margin-top: 4px;
max-height: 220px; overflow-y: auto; padding: 6px;
}
.discover-row { display: flex; gap: 8px; align-items: center; padding: 6px; border-radius: 8px; cursor: pointer; }
.discover-row:hover { background: var(--accent-soft); }
/* Tasks kanban */
.new-task { display: grid; gap: 8px; margin-bottom: 14px; }
.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; align-items: start; }
.kanban-col { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; }
.kanban-col h3 { margin: 4px 4px 10px; }
.task-card { display: grid; gap: 6px; margin-bottom: 8px; }
.task-card.overdue { border-color: #ef4444; }
.overdue-tag { color: #ef4444; font-weight: 800; }
/* Calendar */
.calendar {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px;
}
.cal-head { font-weight: 800; text-align: center; color: var(--muted); padding: 6px; }
.cal-cell {
min-height: 96px; background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 6px; overflow: hidden;
}
.cal-cell.today { border: 2px solid var(--accent); }
.cal-day { font-weight: 800; font-size: 13px; color: var(--muted); }
.cal-task {
font-size: 12px; background: var(--accent-soft); border-radius: 6px;
padding: 2px 4px; margin-top: 3px; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cal-task.done { opacity: 0.55; text-decoration: line-through; }
/* Tables + stats */
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.data-table th { text-align: left; color: var(--muted); font-size: 12px; text-transform: uppercase; padding: 8px; }
.data-table td { padding: 8px; border-top: 1px solid var(--border); }
.data-table tr.running td { background: var(--accent-soft); }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 14px; }
.stat { font-size: 26px; display: grid; gap: 2px; text-align: center; }
.stat strong { font-size: 30px; }
.running-banner { border: 2px solid #ef4444; margin-bottom: 12px; }
/* Timer */
.timetracker { position: relative; }
.timer-btn.running { background: linear-gradient(135deg, #ef4444, #f59e0b); font-variant-numeric: tabular-nums; }
.timer-pop {
position: absolute; right: 0; top: 44px; width: 280px; z-index: 70;
display: grid; gap: 8px;
}
/* Editor */
.tiptap { min-height: 300px; border: 1px solid var(--border); border-radius: 12px; padding: 14px; background: var(--card); }
.tiptap:focus { outline: 2px solid var(--accent); }
.tiptap table { border-collapse: collapse; width: 100%; margin: 8px 0; }
.tiptap th, .tiptap td { border: 1px solid var(--border); padding: 6px 10px; }
.tiptap th { background: var(--accent-soft); }
.tiptap ul[data-type='taskList'] { list-style: none; padding-left: 4px; }
.tiptap ul[data-type='taskList'] li { display: flex; gap: 8px; align-items: flex-start; }
.toolbar { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.toolbar button { padding: 4px 8px; font-size: 13px; }
.toolbar button { padding: 5px 10px; font-size: 13px; }
.comment { border-top: 1px solid var(--border); padding: 8px 0; }
/* ---- Glassmorphism over background image ---- */
.has-bg .layout { background: transparent; }
.glass .sidebar,
.glass .card,
.glass .greeting,
.glass .clock,
.glass .kanban-col,
.glass .cal-cell {
background: color-mix(in srgb, var(--card) 72%, transparent);
backdrop-filter: blur(18px) saturate(1.25);
-webkit-backdrop-filter: blur(18px) saturate(1.25);
}
/* ---- Bell 🔔 ---- */
.bell { position: relative; }
.bell-btn { position: relative; }
.badge {
position: absolute; top: -6px; right: -6px;
background: #ef4444; color: #fff; font-size: 11px; font-weight: 800;
border-radius: 12px; padding: 1px 6px; min-width: 18px; text-align: center;
}
.bell-pop {
position: absolute; right: 0; top: 44px; width: 330px; z-index: 70;
max-height: 60vh; overflow-y: auto; display: grid; gap: 8px;
}
.invite-card { padding: 10px; }
.notif { padding: 8px; border-radius: 8px; cursor: pointer; border-top: 1px solid var(--border); }
.notif.unread { background: var(--accent-soft); }
.notif:hover { background: var(--bg-soft); }
/* ---- Chat 💬 ---- */
.chat-main { display: flex; flex-direction: column; }
.chat-box {
flex: 1; min-height: 50vh; max-height: calc(100vh - 220px);
overflow-y: auto; display: flex; flex-direction: column; gap: 10px;
}
.chat-msg { display: flex; gap: 8px; align-items: flex-start; }
.chat-bubble {
background: var(--bg-soft); border: 1px solid var(--border);
border-radius: 4px 14px 14px 14px; padding: 8px 12px; max-width: 75%;
overflow-wrap: anywhere;
}
.chat-msg.mine { flex-direction: row-reverse; }
.chat-msg.mine .chat-bubble { border-radius: 14px 4px 14px 14px; background: var(--accent-soft); }
.chat-meta { margin-bottom: 2px; }
/* ---- Admin / teams / groups ---- */
.admin-tabs { margin-bottom: 14px; }
.team-tabs { margin-bottom: 12px; }
.team-pill { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; border: none; }
.perm-check { font-size: 13px; }
.perm-check code {
background: var(--bg-soft); border: 1px solid var(--border);
border-radius: 6px; padding: 1px 6px; font-size: 12px;
}
.side-error {
background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca;
border-radius: 10px; padding: 8px 10px; font-size: 13px; margin: 0;
}
[data-theme='dark'] .side-error { background: #3f1d1d; color: #fca5a5; border-color: #7f2d2d; }
.ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- Chat: Discord/Slack mix ---- */
.chat-layout { display: flex; flex-direction: column; }
.chat-grid { display: grid; grid-template-columns: 280px 1fr; gap: 12px; align-items: start; }
.chat-list { display: flex; flex-direction: column; gap: 4px; max-height: calc(100vh - 220px); overflow-y: auto; }
.conv-row {
display: flex; gap: 10px; align-items: center;
padding: 8px; border-radius: 12px; cursor: pointer;
}
.conv-row:hover { background: var(--bg-soft); }
.conv-row.active { background: var(--accent-soft); }
.avatar-wrap { position: relative; flex-shrink: 0; }
.online-dot {
position: absolute; bottom: -1px; right: -1px;
width: 11px; height: 11px; border-radius: 50%;
background: #22c55e; border: 2px solid var(--card);
}
.badge.static { position: static; }
.chat-pane { min-width: 0; display: flex; flex-direction: column; }
.chat-box.slim { max-height: calc(100vh - 340px); min-height: 40vh; }
.typing { font-style: italic; margin: 4px 0; min-height: 18px; }
.search-hits { margin-top: 8px; max-height: 140px; overflow-y: auto; }
.reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.reaction {
background: var(--bg-soft); border: 1px solid var(--border);
border-radius: 14px; padding: 1px 8px; font-size: 13px; cursor: pointer;
}
.reaction.mine { border-color: var(--accent); background: var(--accent-soft); }
.msg-actions { display: flex; gap: 4px; margin-top: 4px; opacity: 0.35; position: relative; }
.chat-bubble:hover .msg-actions { opacity: 1; }
.emoji-pop {
position: absolute; z-index: 30; background: var(--card);
border: 1px solid var(--border); border-radius: 12px; padding: 6px;
display: flex; gap: 2px; font-size: 20px; box-shadow: var(--shadow);
}
.emoji-pop span { cursor: pointer; padding: 2px; border-radius: 6px; }
.emoji-pop span:hover { background: var(--accent-soft); }
.chat-img { max-width: 320px; border-radius: 10px; margin-top: 6px; display: block; }
.attach { cursor: pointer; }
/* ---- Scaling / overflow fixes ---- */
.main { min-width: 0; }
.row > input, .row > select { min-width: 0; flex: 1; }
.row > button { flex-shrink: 0; }
.sidebar select, .sidebar input { min-width: 0; }
.table-scroll { overflow-x: auto; }
.data-table { min-width: 560px; }
img { max-width: 100%; }
.topbar { flex-wrap: wrap; }
input[type='file'] { padding: 4px; }
@media (max-width: 900px) {
.kanban { grid-template-columns: 1fr; }
.sidebar { width: 240px; min-width: 240px; }
.settings-body { flex-direction: column; }
.settings-tabs { flex-direction: row; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; }
}
@media (max-width: 640px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; min-width: 0; max-height: none; position: static; }
.main { padding: 16px; }
.top-right { top: 8px; right: 8px; }
.clock { font-size: 20px; }
.greeting { flex-direction: column; align-items: flex-start; }
}
+21
View File
@@ -0,0 +1,21 @@
<script setup>
import { computed } from 'vue';
import { fileUrl } from '../files';
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
const src = computed(() => {
const a = props.avatar || '';
if (a.startsWith('http') || a.startsWith('/uploads') || a.startsWith('data:')) {
return fileUrl(a);
}
return '';
});
const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase());
</script>
<template>
<span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name">
<img v-if="src" :src="src" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<span v-else-if="avatar">{{ avatar }}</span>
<span v-else>{{ letter }}</span>
</span>
</template>
+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>
@@ -0,0 +1,41 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useLocaleStore } from '../stores/locale';
const props = defineProps({ name: String });
const L = useLocaleStore();
const now = ref(new Date());
let timer = null;
onMounted(() => { timer = setInterval(() => (now.value = new Date()), 1000); });
onUnmounted(() => clearInterval(timer));
const period = computed(() => {
const h = now.value.getHours();
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '☀️' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '☕' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '🌤️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌆' };
return { key: 'greet.night', emoji: '🌙' };
});
const clock = computed(() =>
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
);
const date = computed(() =>
now.value.toLocaleDateString(
L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US',
{ weekday: 'long', day: 'numeric', month: 'long' },
),
);
</script>
<template>
<div class="greeting">
<div>
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
</div>
<div class="clock">{{ clock }}</div>
</div>
</template>
+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>
+21 -6
View File
@@ -1,21 +1,34 @@
<script setup>
import { ref } from 'vue';
import api from '../api';
import { useLocaleStore } from '../stores/locale';
import UserSearch from './UserSearch.vue';
import Icon from './Icon.vue';
const props = defineProps({ workspaceId: String });
const emit = defineEmits(['invited']);
const L = useLocaleStore();
const email = ref('');
const picked = ref(null);
const role = ref('member');
const msg = ref('');
const err = ref('');
function onPick(u) {
picked.value = u;
email.value = u.email;
}
async function invite() {
msg.value = '';
err.value = '';
const target = email.value.trim();
if (!target) return;
try {
await api.post(`/workspaces/${props.workspaceId}/members`, { email: email.value, role: role.value });
msg.value = `Invited ${email.value} as ${role.value}`;
await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
msg.value = `✨ ${target} → ${role.value}`;
email.value = '';
picked.value = null;
emit('invited');
} catch (e) {
err.value = e.response?.data?.error || 'Invite failed';
@@ -25,17 +38,19 @@ async function invite() {
<template>
<div class="card">
<h4>Invite collaborator</h4>
<p class="muted">They must register first with the same email.</p>
<div class="row">
<h4><Icon name="mail" :size="16" /> {{ L.t('inv.title') }}</h4>
<p class="muted">{{ L.t('inv.hint') }}</p>
<UserSearch @pick="onPick" />
<div class="row" style="margin-top: 8px">
<input v-model="email" placeholder="teammate@example.com" />
<select v-model="role" style="max-width: 130px">
<option value="viewer">viewer</option>
<option value="member">member</option>
<option value="admin">admin</option>
</select>
<button @click="invite">Invite</button>
<button @click="invite">{{ L.t('inv.btn') }}</button>
</div>
<p v-if="picked" class="muted">🔎 {{ picked.name }} &lt;{{ picked.email }}&gt;</p>
<p v-if="msg" style="color: green">{{ msg }}</p>
<p v-if="err" style="color: red">{{ err }}</p>
</div>
+251
View File
@@ -0,0 +1,251 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import api from '../api';
import { uploadImage } from '../files';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import { useThemeStore } from '../stores/theme';
import { useUiStore } from '../stores/ui';
import Avatar from './Avatar.vue';
import Icon from './Icon.vue';
const auth = useAuthStore();
const L = useLocaleStore();
const theme = useThemeStore();
const ui = useUiStore();
const form = reactive({ name: '', email: '', avatar: '' });
const curPw = ref('');
const newPw = ref('');
const msg = ref('');
const err = ref('');
const bgUrl = ref('');
const bgBlur = ref(true);
const bgGlass = ref(true);
const bgPreset = ref('none');
const uploading = ref('');
const GRADIENTS = {
none: '',
sunset: 'linear-gradient(135deg,#ff9a56,#ff5e78 55%,#a855f7)',
ocean: 'linear-gradient(135deg,#0ea5e9,#6366f1 55%,#14b8a6)',
forest: 'linear-gradient(135deg,#166534,#4d7c0f 60%,#a3a380)',
midnight: 'linear-gradient(135deg,#0f172a,#4c1d95 60%,#0ea5e9)',
};
const EMOJIS = ['🦊', '🐼', '🐸', '🚀', '🌙', '🔥', '🍩', '🎧', '🌵', '⚡'];
watch(
() => ui.settingsOpen,
(open) => {
if (open && auth.user) {
form.name = auth.user.name || '';
form.email = auth.user.email || '';
form.avatar = auth.user.avatar || '';
bgUrl.value = auth.user.bgImage?.startsWith('http') ? auth.user.bgImage : '';
bgPreset.value = Object.entries(GRADIENTS).find(
([, v]) => v && v === auth.user.bgImage,
)?.[0] || (auth.user.bgImage ? 'custom' : 'none');
bgBlur.value = auth.user.bgBlur !== false;
bgGlass.value = auth.user.bgGlass !== false;
msg.value = '';
err.value = '';
}
},
);
function bgValue() {
if (bgPreset.value === 'custom') return bgUrl.value.trim();
return GRADIENTS[bgPreset.value] || '';
}
function persistUser(user) {
auth.user = user;
localStorage.setItem('user', JSON.stringify(user));
auth.applyPrefs();
}
async function saveProfile() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', {
name: form.name.trim(), email: form.email.trim(), avatar: form.avatar.trim(),
});
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function changePw() {
msg.value = '';
err.value = '';
try {
await api.patch('/users/me/password', { currentPassword: curPw.value, newPassword: newPw.value });
curPw.value = '';
newPw.value = '';
msg.value = L.t('set.pwChanged');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function setTheme(mode) {
theme.set(mode);
try {
const { data } = await api.patch('/users/me', { theme: mode });
persistUser(data.user);
} catch { /* offline-friendly: theme still applies locally */ }
}
async function setLocale(l) {
L.set(l);
try {
const { data } = await api.patch('/users/me', { locale: l });
persistUser(data.user);
} catch { /* keep local */ }
}
async function saveBackground() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', {
bg_image: bgValue(), bg_blur: bgBlur.value, bg_glass: bgGlass.value,
});
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function uploadAvatar(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading.value = 'avatar';
err.value = '';
try {
form.avatar = await uploadImage(file);
const { data } = await api.patch('/users/me', { avatar: form.avatar });
persistUser(data.user);
msg.value = L.t('set.saved');
} catch {
err.value = 'Upload failed';
} finally {
uploading.value = '';
}
}
async function uploadBg(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading.value = 'bg';
err.value = '';
try {
bgUrl.value = await uploadImage(file);
bgPreset.value = 'custom';
const { data } = await api.patch('/users/me', { bg_image: bgUrl.value });
persistUser(data.user);
msg.value = L.t('set.saved');
} catch {
err.value = 'Upload failed';
} finally {
uploading.value = '';
}
}
</script>
<template>
<div v-if="ui.settingsOpen" class="settings-overlay" @click.self="ui.closeSettings()">
<div class="settings-window">
<div class="settings-titlebar">
<div class="traffic">
<span class="dot red" @click="ui.closeSettings()"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<strong><Icon name="settings" :size="18" /> {{ L.t('set.title') }}</strong>
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
</div>
<div class="settings-body">
<nav class="settings-tabs">
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'"><Icon name="user" :size="16" /> {{ L.t('set.profile') }}</button>
<button :class="{ active: ui.settingsTab === 'appearance' }" @click="ui.settingsTab = 'appearance'"><Icon name="sun" :size="16" /> {{ L.t('set.appearance') }}</button>
<button :class="{ active: ui.settingsTab === 'language' }" @click="ui.settingsTab = 'language'"><Icon name="globe" :size="16" /> {{ L.t('set.language') }}</button>
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'"><Icon name="image" :size="16" /> {{ L.t('set.background') }}</button>
</nav>
<section class="settings-content">
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
<div class="row">
<Avatar :name="form.name" :avatar="form.avatar" :size="48" />
<div>
<label>{{ L.t('set.avatar') }}</label>
<input v-model="form.avatar" :placeholder="L.t('set.avatarHint')" />
</div>
</div>
<div class="emoji-row">
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
</div>
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadAvatar" /> {{ uploading === 'avatar' ? '⏳' : L.t('set.upload') }}</label>
<p class="muted">✓ {{ L.t('set.autoSaved') }}</p>
<label>{{ L.t('set.name') }}</label>
<input v-model="form.name" />
<label>{{ L.t('set.email') }}</label>
<input v-model="form.email" type="email" />
<button @click="saveProfile">{{ L.t('set.save') }}</button>
<hr />
<label>{{ L.t('set.curPw') }}</label>
<input v-model="curPw" type="password" />
<label>{{ L.t('set.newPw') }}</label>
<input v-model="newPw" type="password" />
<button class="ghost" @click="changePw">{{ L.t('set.changePw') }}</button>
</div>
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
<label>{{ L.t('set.theme') }}</label>
<div class="row">
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')"><Icon name="sun" :size="16" /> {{ L.t('set.light') }}</button>
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')"><Icon name="moon" :size="16" /> {{ L.t('set.dark') }}</button>
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')"><Icon name="desktop" :size="16" /> {{ L.t('set.system') }}</button>
</div>
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
</div>
<div v-if="ui.settingsTab === 'language'" style="display: grid; gap: 10px">
<button :class="{ ghost: L.locale !== 'de' }" @click="setLocale('de')">🇩🇪 Deutsch</button>
<button :class="{ ghost: L.locale !== 'en' }" @click="setLocale('en')">🇬🇧 English</button>
<button :class="{ ghost: L.locale !== 'zh' }" @click="setLocale('zh')">🇨🇳 中文</button>
</div>
<div v-if="ui.settingsTab === 'background'" style="display: grid; gap: 10px">
<div class="preset-grid">
<div
v-for="(grad, key) in GRADIENTS"
:key="key"
class="preset"
:class="{ active: bgPreset === key }"
:style="grad ? { background: grad } : {}"
@click="bgPreset = key"
>
{{ key === 'none' ? '🚫' : '' }}
</div>
</div>
<label>{{ L.t('set.bgUrl') }}</label>
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadBg" /> {{ uploading === 'bg' ? '⏳' : L.t('set.upload') }}</label>
<p class="muted">✓ {{ L.t('set.autoSaved') }}</p>
<p class="muted">{{ L.t('set.bgUrlHint') }}</p>
<label class="row"><input type="checkbox" v-model="bgBlur" style="width: auto" /> {{ L.t('set.bgBlur') }}</label>
<label class="row"><input type="checkbox" v-model="bgGlass" style="width: auto" /> {{ L.t('set.bgGlass') }}</label>
<button @click="saveBackground">{{ L.t('set.save') }}</button>
</div>
<p v-if="msg" style="color: green">{{ msg }}</p>
<p v-if="err" style="color: red">{{ err }}</p>
</section>
</div>
</div>
</div>
</template>
+196
View File
@@ -0,0 +1,196 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import { useUiStore } from '../stores/ui';
import PageTree from './PageTree.vue';
import Avatar from './Avatar.vue';
import Icon from './Icon.vue';
const props = defineProps({ activePageId: String });
const auth = useAuthStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const ui = useUiStore();
const router = useRouter();
const newWs = ref('');
const newPage = ref('');
const newBook = ref('');
const search = ref('');
const results = ref([]);
const sideErr = ref('');
const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}'));
function toggleBook(id) {
collapsed.value[id] = !collapsed.value[id];
localStorage.setItem('collapsedBooks', JSON.stringify(collapsed.value));
}
function fail(e, fallback) {
sideErr.value = e.response?.data?.error || fallback || 'Error';
setTimeout(() => (sideErr.value = ''), 4000);
}
async function refresh() {
try {
await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
} catch (e) {
fail(e);
}
}
async function switchWs(id) {
ws.setCurrent(id);
await refresh();
}
async function createWs() {
if (!newWs.value.trim()) return;
try {
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await refresh();
} catch (e) {
fail(e);
}
}
async function createPage(bookId = null, parentId = null) {
try {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, parent_id: parentId, title,
});
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
} catch (e) {
fail(e);
}
}
async function createBook() {
if (!newBook.value.trim()) return;
try {
await ws.createBook(newBook.value.trim(), '📚');
newBook.value = '';
} catch (e) {
fail(e);
}
}
async function deleteBook(id) {
if (!confirm('✕?')) return;
try {
await api.delete(`/books/${id}`);
await ws.fetchBooks();
await ws.fetchPages();
} catch (e) {
fail(e);
}
}
async function doSearch() {
if (!search.value.trim()) { results.value = []; return; }
try {
const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId },
});
results.value = data.results;
} catch {
results.value = [];
}
}
function logout() {
auth.logout();
router.push('/login');
}
onMounted(refresh);
</script>
<template>
<aside class="sidebar">
<div class="user-chip">
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
<strong style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis">{{ auth.user?.name }}</strong>
<button class="ghost icon-btn" @click="ui.openSettings()" title="Settings"><Icon name="settings" /></button>
</div>
<p v-if="sideErr" class="side-error">{{ sideErr }}</p>
<div class="row">
<select :value="ws.currentId" @change="switchWs($event.target.value)" style="flex: 1; min-width: 0">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">
{{ w.name }}{{ w.team_name ? ` · TEAM_${w.team_name}` : '' }} ({{ w.role }})
</option>
</select>
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')"><Icon name="refresh" /></button>
</div>
<div class="row">
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
<button @click="createWs">+</button>
</div>
<nav class="side-nav">
<router-link to="/"><Icon name="home" /> {{ L.t('nav.dashboard') }}</router-link>
<router-link to="/tasks"><Icon name="tasks" /> {{ L.t('nav.tasks') }}</router-link>
<router-link to="/calendar"><Icon name="calendar" /> {{ L.t('nav.calendar') }}</router-link>
<router-link to="/time"><Icon name="timer" /> {{ L.t('nav.time') }}</router-link>
<router-link to="/chat"><Icon name="chat" /> {{ L.t('nav.chat') }}</router-link>
<router-link v-if="auth.user?.isAdmin" to="/admin"><Icon name="shield" /> {{ L.t('nav.admin') }}</router-link>
</nav>
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
<div v-if="results.length" class="card search-results">
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
🔍 {{ r.title }}
</div>
</div>
<div class="side-label">{{ L.t('side.books') }}</div>
<div class="row">
<input v-model="newBook" :placeholder="L.t('side.newBookPh')" @keyup.enter="createBook" />
<button @click="createBook">+</button>
</div>
<div v-for="b in ws.books" :key="b.id" class="book">
<div class="tree-item book-head" @click="toggleBook(b.id)">
<span class="ellipsis">{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span class="row">
<button class="ghost mini" @click.stop="createPage(b.id, null)" title="+">+</button>
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="✕">✕</button>
</span>
</div>
<div v-if="!collapsed[b.id]" class="book-pages">
<PageTree
:nodes="ws.treeFor(b.id)" :active-id="activePageId"
@open="router.push(`/page/${$event}`)"
@add-child="createPage(b.id, $event)"
/>
</div>
</div>
<div class="side-label">{{ L.t('side.pages') }}</div>
<div class="row">
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage(null, null)" />
<button @click="createPage(null, null)">+</button>
</div>
<PageTree
:nodes="ws.tree()" :active-id="activePageId"
@open="router.push(`/page/${$event}`)"
@add-child="createPage(null, $event)"
/>
<div class="side-footer">
<span class="muted">{{ ws.role }}</span>
<button class="ghost" @click="logout"><Icon name="logout" /> {{ L.t('nav.logout') }}</button>
</div>
</aside>
</template>
-12
View File
@@ -1,12 +0,0 @@
<script setup>
import { useThemeStore } from '../stores/theme';
const theme = useThemeStore();
</script>
<template>
<div class="row">
<button class="ghost" @click="theme.set('light')" :disabled="theme.resolved === 'light' && theme.mode !== 'system'">☀ Light</button>
<button class="ghost" @click="theme.set('dark')" :disabled="theme.resolved === 'dark' && theme.mode !== 'system'">🌙 Dark</button>
<button class="ghost" @click="theme.toggle()">Toggle</button>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useTimeStore, formatDuration } from '../stores/time';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Icon from './Icon.vue';
const time = useTimeStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const open = ref(false);
const desc = ref('');
const bookId = ref('');
const tick = ref(0);
let timer = null;
onMounted(() => { timer = setInterval(() => (tick.value += 1), 1000); });
onUnmounted(() => clearInterval(timer));
const elapsed = computed(() => {
void tick.value;
return time.open
? formatDuration((Date.now() - new Date(time.open.started_at).getTime()) / 1000)
: '00:00:00';
});
async function toggle() {
if (time.open) {
await time.stop();
open.value = false;
} else {
open.value = !open.value;
}
}
async function start() {
await time.start(desc.value, bookId.value || null);
desc.value = '';
open.value = false;
}
</script>
<template>
<div class="timetracker">
<button
class="ghost timer-btn"
:class="{ running: !!time.open }"
@click="toggle"
:title="L.t('time.title')"
>
<Icon name="timer" :size="16" /> {{ time.open ? elapsed : L.t('time.start') }}
</button>
<div v-if="open && !time.open" class="card timer-pop">
<input v-model="desc" :placeholder="L.t('time.descPh')" />
<select v-model="bookId">
<option value="">{{ L.t('time.book') }}: {{ L.t('time.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<div class="row">
<button @click="start">{{ L.t('time.start') }}</button>
<button class="ghost" @click="router.push('/time')">{{ L.t('time.entries') }}</button>
</div>
</div>
</div>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { ref, watch } from 'vue';
import api from '../api';
import { useLocaleStore } from '../stores/locale';
import Avatar from './Avatar.vue';
const props = defineProps({ workspaceId: String });
const emit = defineEmits(['pick']);
const L = useLocaleStore();
const q = ref('');
const results = ref([]);
const busy = ref(false);
let timer = null;
watch(q, () => {
clearTimeout(timer);
timer = setTimeout(search, 250);
});
async function search() {
const query = q.value.trim();
if (query.length < 2) { results.value = []; return; }
busy.value = true;
try {
const { data } = await api.get('/users/discover', { params: { q: query } });
results.value = data.users;
} catch {
results.value = [];
} finally {
busy.value = false;
}
}
function pick(u) {
emit('pick', u);
q.value = '';
results.value = [];
}
</script>
<template>
<div class="discover">
<input v-model="q" :placeholder="L.t('inv.ph')" />
<div v-if="q.trim().length >= 2" class="discover-results card">
<div v-if="busy" class="muted">{{ L.t('c.loading') }}</div>
<div v-for="u in results" :key="u.id" class="discover-row" @click="pick(u)">
<Avatar :name="u.name" :avatar="u.avatar" :size="24" />
<span><strong>{{ u.name }}</strong><br /><span class="muted">{{ u.email }}</span></span>
</div>
<div v-if="!busy && !results.length" class="muted">∅</div>
</div>
</div>
</template>
+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
}
+102
View File
@@ -0,0 +1,102 @@
export default {
nav: {
dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung',
admin: 'Admin', logout: 'Abmelden', chat: 'Chat',
},
side: {
workspaces: 'Workspaces', newWorkspace: 'Neuer Workspace', searchPh: 'Seiten suchen …',
newPagePh: 'Neue Seite', newBookPh: 'Neues Book', books: '📚 Books', pages: '📄 Seiten',
members: 'Mitglieder', role: 'Rolle', refresh: 'Aktualisieren', teamBadge: 'Team',
},
auth: {
title: 'Willkommen zurück', sub: 'Melde dich an.',
email: 'E-Mail', password: 'Passwort', btn: 'Anmelden',
locked: 'Accounts werden vom Admin erstellt.',
},
bell: {
title: 'Mitteilungen', invites: 'Einladungen', notifs: 'Neuigkeiten',
empty: 'Alles gelesen. ✨', as: 'als', accept: 'Annehmen', decline: 'Ablehnen',
readAll: 'Alle als gelesen markieren',
},
chat: {
title: 'Team-Chat', ph: 'Nachricht schreiben …', send: 'Senden',
empty: 'Noch keine Nachrichten. Sag Hallo! 👋',
},
teams: {
title: 'Teams', new: 'Neues Team', ph: 'Teamname …',
members: 'Mitglieder', makeWs: 'Workspace für Team erstellen',
wsName: 'Workspace-Name …', manager: 'Manager', member: 'Mitglied',
addMember: 'Hinzufügen', pickUser: 'User wählen …',
noTeams: 'Noch keine Teams. Erstelle eins! 🚀',
},
groups: {
title: 'Gruppen & Rechte', new: 'Neue Gruppe', ph: 'Gruppenname …',
members: 'Mitglieder', save: 'Speichern', perms: 'Rechte',
},
greet: {
morning: 'Guten Morgen', midday: 'Mahlzeit', afternoon: 'Guten Tag',
evening: 'Guten Abend', night: 'Gute Nacht',
},
set: {
title: 'Einstellungen', profile: 'Profil', appearance: 'Darstellung',
language: 'Sprache', background: 'Hintergrund',
name: 'Anzeigename', email: 'E-Mail', avatar: 'Profilbild (Emoji oder Bild-URL)',
avatarHint: 'Z. B. 🦊 oder https://…/foto.png',
curPw: 'Aktuelles Passwort', newPw: 'Neues Passwort (min. 8 Zeichen)',
changePw: 'Passwort ändern', pwChanged: 'Passwort geändert ✅',
save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema',
light: 'Hell', dark: 'Dunkel', system: 'System',
bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:',
bgBlur: 'Hintergrund weichzeichnen (Blur)', bgGlass: 'Widgets als Glas darstellen',
upload: 'Bild hochladen …', autoSaved: 'Wird sofort übernommen ✓', close: 'Schließen',
presetNone: 'Kein Bild', presetCustom: 'Eigene URL',
},
task: {
title: 'Aufgaben', new: 'Neue Aufgabe', ph: 'Was ist zu tun?',
todo: '📝 Offen', doing: '⏳ In Arbeit', done: '✅ Erledigt',
priority: 'Prio', low: 'Niedrig', normal: 'Normal', high: 'Hoch',
due: 'Fällig', noDue: 'Ohne Termin', book: 'Book', page: 'Seite',
team: 'Team', assignee: 'Zuständig', allTeams: 'Alle Teams',
none: '—', overdue: 'Überfällig!', toTodo: '← Offen', toDoing: 'In Arbeit →',
toDone: 'Erledigt →', reopen: '↩ Öffnen',
},
cal: { title: 'Kalender', today: 'Heute' },
time: {
title: 'Zeiterfassung', start: '▶ Start', stop: '⏹ Stopp',
descPh: 'Woran arbeitest du?', book: 'Book', none: '—',
entries: 'Einträge', dur: 'Dauer', running: 'läuft …',
total: 'Gesamt', noEntries: 'Noch keine Einträge. Starte oben den Timer! ⏱️',
started: 'Start', ended: 'Ende',
},
admin: {
title: 'Admin-Dashboard', overview: '📊 Übersicht', users: '👥 Benutzer',
name: 'Name', email: 'E-Mail', role: 'Rolle', admin: 'Admin', user: 'User',
workspaces: 'Workspaces', joined: 'Dabei seit', makeAdmin: 'Zum Admin machen',
unmake: 'Admin entziehen', del: 'Löschen', confirm: 'Wirklich löschen?',
you: '(du)', books: 'Books', pages: 'Seiten', tasks: 'Aufgaben', time: 'Zeiteinträge',
createUser: 'Benutzer anlegen', newPw: 'Passwort', create: 'Anlegen', created: 'Angelegt ✅',
},
c: {
save: 'Speichern', cancel: 'Abbrechen', delete: 'Löschen', create: 'Anlegen',
back: 'Zurück', loading: 'Lädt …',
},
inv: {
title: 'Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.',
ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen',
pick: 'Person wählen …',
},
chat: {
title: 'Team-Chat', ph: 'Nachricht schreiben …', send: 'Senden',
empty: 'Noch keine Nachrichten. Sag Hallo! 👋',
tabs: 'Chats', teamTabs: 'Team-Chats', search: 'Suchen …',
type: 'Nachricht …', edit: 'Bearbeiten', edited: '(bearbeitet)',
attach: 'Bild anhängen', typing: 'schreibt …', online: 'online',
noConv: 'Wähle links einen Chat oder starte einen neuen.',
pickUser: 'User suchen für neuen Chat …', unread: 'neu',
},
perm: {
title: 'Rechte pro Benutzer', allow: 'Erlauben', deny: 'Verbieten',
inherit: 'Vererbt', manage: 'Rechte',
},
com: { title: 'Kommentare', ph: 'Kommentar schreiben …', post: 'Posten' },
};
+102
View File
@@ -0,0 +1,102 @@
export default {
nav: {
dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking',
admin: 'Admin', logout: 'Log out', chat: 'Chat',
},
side: {
workspaces: 'Workspaces', newWorkspace: 'New workspace', searchPh: 'Search pages …',
newPagePh: 'New page', newBookPh: 'New book', books: '📚 Books', pages: '📄 Pages',
members: 'Members', role: 'Role', refresh: 'Refresh', teamBadge: 'Team',
},
auth: {
title: 'Welcome back', sub: 'Log in to your workspace.',
email: 'Email', password: 'Password', btn: 'Log in',
locked: 'Accounts are created by an admin.',
},
bell: {
title: 'Notifications', invites: 'Invites', notifs: 'Updates',
empty: 'All caught up. ✨', as: 'as', accept: 'Accept', decline: 'Decline',
readAll: 'Mark all as read',
},
chat: {
title: 'Team chat', ph: 'Write a message …', send: 'Send',
empty: 'No messages yet. Say hi! 👋',
},
teams: {
title: 'Teams', new: 'New team', ph: 'Team name …',
members: 'Members', makeWs: 'Create workspace for team',
wsName: 'Workspace name …', manager: 'Manager', member: 'Member',
addMember: 'Add', pickUser: 'Pick user …',
noTeams: 'No teams yet. Create one! 🚀',
},
groups: {
title: 'Groups & permissions', new: 'New group', ph: 'Group name …',
members: 'Members', save: 'Save', perms: 'Permissions',
},
greet: {
morning: 'Good morning', midday: 'Enjoy your lunch', afternoon: 'Good afternoon',
evening: 'Good evening', night: 'Good night',
},
set: {
title: 'Settings', profile: 'Profile', appearance: 'Appearance',
language: 'Language', background: 'Background',
name: 'Display name', email: 'Email', avatar: 'Avatar (emoji or image URL)',
avatarHint: 'E.g. 🦊 or https://…/photo.png',
curPw: 'Current password', newPw: 'New password (min. 8 chars)',
changePw: 'Change password', pwChanged: 'Password changed ✅',
save: 'Save', saved: 'Saved ✅', theme: 'Color scheme',
light: 'Light', dark: 'Dark', system: 'System',
bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:',
bgBlur: 'Blur background', bgGlass: 'Glassmorphism widgets',
upload: 'Upload image …', autoSaved: 'Applied instantly ✓', close: 'Close',
presetNone: 'No image', presetCustom: 'Custom URL',
},
task: {
title: 'Tasks', new: 'New task', ph: 'What needs doing?',
todo: '📝 Todo', doing: '⏳ Doing', done: '✅ Done',
priority: 'Priority', low: 'Low', normal: 'Normal', high: 'High',
due: 'Due', noDue: 'No due date', book: 'Book', page: 'Page',
team: 'Team', assignee: 'Assignee', allTeams: 'All teams',
none: '—', overdue: 'Overdue!', toTodo: '← Todo', toDoing: 'Doing →',
toDone: 'Done →', reopen: '↩ Reopen',
},
cal: { title: 'Calendar', today: 'Today' },
time: {
title: 'Time tracking', start: '▶ Start', stop: '⏹ Stop',
descPh: 'What are you working on?', book: 'Book', none: '—',
entries: 'Entries', dur: 'Duration', running: 'running …',
total: 'Total', noEntries: 'No entries yet. Start the timer above! ⏱️',
started: 'Started', ended: 'Ended',
},
admin: {
title: 'Admin dashboard', overview: '📊 Overview', users: '👥 Users',
name: 'Name', email: 'Email', role: 'Role', admin: 'Admin', user: 'User',
workspaces: 'Workspaces', joined: 'Joined', makeAdmin: 'Make admin',
unmake: 'Remove admin', del: 'Delete', confirm: 'Really delete?',
you: '(you)', books: 'Books', pages: 'Pages', tasks: 'Tasks', time: 'Time entries',
createUser: 'Create user', newPw: 'Password', create: 'Create', created: 'Created ✅',
},
c: {
save: 'Save', cancel: 'Cancel', delete: 'Delete', create: 'Create',
back: 'Back', loading: 'Loading …',
},
inv: {
title: 'Invite collaborator', hint: 'Type to search – or enter a full email.',
ph: 'Search name or email …', btn: 'Invite', done: 'Invited',
pick: 'Pick a person …',
},
chat: {
title: 'Team chat', ph: 'Write a message …', send: 'Send',
empty: 'No messages yet. Say hi! 👋',
tabs: 'Chats', teamTabs: 'Team chats', search: 'Search …',
type: 'Message …', edit: 'Edit', edited: '(edited)',
attach: 'Attach image', typing: 'is typing …', online: 'online',
noConv: 'Pick a chat on the left or start a new one.',
pickUser: 'Search user for new chat …', unread: 'new',
},
perm: {
title: 'Per-user permissions', allow: 'Allow', deny: 'Deny',
inherit: 'Inherited', manage: 'Rights',
},
com: { title: 'Comments', ph: 'Write a comment …', post: 'Post' },
};
+102
View File
@@ -0,0 +1,102 @@
export default {
nav: {
dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录',
admin: '管理', logout: '退出登录', chat: '聊天',
},
side: {
workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …',
newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面',
members: '成员', role: '角色', refresh: '刷新', teamBadge: '团队',
},
auth: {
title: '欢迎回来', sub: '登录到你的工作区。',
email: '电子邮件', password: '密码', btn: '登录',
locked: '账号由管理员创建。',
},
bell: {
title: '通知', invites: '邀请', notifs: '动态',
empty: '全部已读。✨', as: '以', accept: '接受', decline: '拒绝',
readAll: '全部标为已读',
},
chat: {
title: '团队聊天', ph: '写消息 …', send: '发送',
empty: '还没有消息,打个招呼吧!👋',
},
teams: {
title: '团队', new: '新建团队', ph: '团队名称 …',
members: '成员', makeWs: '为团队创建工作区',
wsName: '工作区名称 …', manager: '管理员', member: '成员',
addMember: '添加', pickUser: '选择用户 …',
noTeams: '还没有团队,创建一个吧!🚀',
},
groups: {
title: '分组与权限', new: '新建分组', ph: '分组名称 …',
members: '成员', save: '保存', perms: '权限',
},
greet: {
morning: '早上好', midday: '午餐愉快', afternoon: '下午好',
evening: '晚上好', night: '晚安',
},
set: {
title: '设置', profile: '个人资料', appearance: '外观',
language: '语言', background: '背景',
name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)',
avatarHint: '例如 🦊 或 https://…/photo.png',
curPw: '当前密码', newPw: '新密码(至少 8 位)',
changePw: '修改密码', pwChanged: '密码已修改 ✅',
save: '保存', saved: '已保存 ✅', theme: '配色方案',
light: '浅色', dark: '深色', system: '跟随系统',
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
bgBlur: '模糊背景', bgGlass: '玻璃拟态组件',
upload: '上传图片 …', autoSaved: '即时生效 ✓', close: '关闭',
presetNone: '无背景', presetCustom: '自定义链接',
},
task: {
title: '任务', new: '新建任务', ph: '要做什么?',
todo: '📝 待办', doing: '⏳ 进行中', done: '✅ 已完成',
priority: '优先级', low: '低', normal: '中', high: '高',
due: '截止', noDue: '无截止日期', book: '书册', page: '页面',
team: '团队', assignee: '负责人', allTeams: '全部团队',
none: '—', overdue: '已逾期!', toTodo: '← 待办', toDoing: '进行中 →',
toDone: '完成 →', reopen: '↩ 重新打开',
},
cal: { title: '日历', today: '今天' },
time: {
title: '时间记录', start: '▶ 开始', stop: '⏹ 停止',
descPh: '在忙什么?', book: '书册', none: '—',
entries: '记录', dur: '时长', running: '计时中 …',
total: '总计', noEntries: '还没有记录,点击上方开始计时!⏱️',
started: '开始', ended: '结束',
},
admin: {
title: '管理面板', overview: '📊 总览', users: '👥 用户',
name: '名称', email: '邮箱', role: '角色', admin: '管理员', user: '用户',
workspaces: '工作区', joined: '加入时间', makeAdmin: '设为管理员',
unmake: '取消管理员', del: '删除', confirm: '确认删除?',
you: '(你)', books: '书册', pages: '页面', tasks: '任务', time: '时间记录',
createUser: '创建用户', newPw: '密码', create: '创建', created: '已创建 ✅',
},
c: {
save: '保存', cancel: '取消', delete: '删除', create: '创建',
back: '返回', loading: '加载中 …',
},
inv: {
title: '邀请协作者', hint: '输入搜索,或填写完整邮箱。',
ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请',
pick: '选择人员 …',
},
chat: {
title: '团队聊天', ph: '写消息 …', send: '发送',
empty: '还没有消息,打个招呼吧!👋',
tabs: '私聊', teamTabs: '团队聊天', search: '搜索 …',
type: '消息 …', edit: '编辑', edited: '(已编辑)',
attach: '添加图片', typing: '正在输入 …', online: '在线',
noConv: '在左侧选择聊天或开始新聊天。',
pickUser: '搜索用户开始新聊天 …', unread: '新',
},
perm: {
title: '用户权限', allow: '允许', deny: '拒绝',
inherit: '继承', manage: '权限',
},
com: { title: '评论', ph: '写评论 …', post: '发送' },
};
+2
View File
@@ -3,10 +3,12 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { useThemeStore } from './stores/theme';
import { useLocaleStore } from './stores/locale';
import './assets/style.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
useLocaleStore().set(localStorage.getItem('locale') || 'en');
useThemeStore().apply();
+11 -2
View File
@@ -2,17 +2,25 @@ import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useSetupStore } from '../stores/setup';
import Login from '../views/Login.vue';
import Register from '../views/Register.vue';
import Setup from '../views/Setup.vue';
import Dashboard from '../views/Dashboard.vue';
import PageEditor from '../views/PageEditor.vue';
import Tasks from '../views/Tasks.vue';
import Calendar from '../views/Calendar.vue';
import Time from '../views/Time.vue';
import Chat from '../views/Chat.vue';
import Admin from '../views/Admin.vue';
const routes = [
{ path: '/setup', component: Setup },
{ path: '/login', component: Login, meta: { guest: true } },
{ path: '/register', component: Register, meta: { guest: true } },
{ path: '/', component: Dashboard, meta: { auth: true } },
{ path: '/page/:id', component: PageEditor, meta: { auth: true } },
{ path: '/tasks', component: Tasks, meta: { auth: true } },
{ path: '/calendar', component: Calendar, meta: { auth: true } },
{ path: '/time', component: Time, meta: { auth: true } },
{ path: '/chat', component: Chat, meta: { auth: true } },
{ path: '/admin', component: Admin, meta: { auth: true, admin: true } },
];
const router = createRouter({ history: createWebHistory(), routes });
@@ -25,6 +33,7 @@ router.beforeEach(async (to) => {
const auth = useAuthStore();
if (to.meta.auth && !auth.isLoggedIn) return '/login';
if (to.meta.guest && auth.isLoggedIn) return '/';
if (to.meta.admin && !auth.user?.isAdmin) return '/';
return true;
});
+6
View File
@@ -1,4 +1,5 @@
import { io } from 'socket.io-client';
import { usePresenceStore } from './stores/presence';
let socket = null;
@@ -9,6 +10,11 @@ export function connectSocket() {
socket?.disconnect();
const url = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';
socket = io(url || undefined, { auth: { token } });
socket.on('presence:online', (ids) => {
try {
usePresenceStore().online = ids || [];
} catch { /* store not ready */ }
});
return socket;
}
+9
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia';
import api from '../api';
import { connectSocket, disconnectSocket } from '../socket';
import { useLocaleStore } from './locale';
import { useThemeStore } from './theme';
export const useAuthStore = defineStore('auth', {
state: () => ({
@@ -11,6 +13,11 @@ export const useAuthStore = defineStore('auth', {
isLoggedIn: (s) => !!s.token,
},
actions: {
applyPrefs() {
if (!this.user) return;
useLocaleStore().set(this.user.locale || 'en');
useThemeStore().set(this.user.theme || 'system');
},
async register(email, name, password) {
const { data } = await api.post('/auth/register', { email, name, password });
this.apply(data);
@@ -24,6 +31,7 @@ export const useAuthStore = defineStore('auth', {
this.token = data.token;
localStorage.setItem('user', JSON.stringify(data.user));
localStorage.setItem('token', data.token);
this.applyPrefs();
connectSocket();
},
async fetchMe() {
@@ -31,6 +39,7 @@ export const useAuthStore = defineStore('auth', {
const { data } = await api.get('/auth/me');
this.user = data.user;
localStorage.setItem('user', JSON.stringify(data.user));
this.applyPrefs();
},
logout() {
this.user = null;
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia';
import de from '../i18n/de';
import en from '../i18n/en';
import zh from '../i18n/zh';
const dicts = { de, en, zh };
export const useLocaleStore = defineStore('locale', {
state: () => ({
locale: localStorage.getItem('locale') || 'en',
}),
actions: {
set(l) {
if (!dicts[l]) return;
this.locale = l;
localStorage.setItem('locale', l);
document.documentElement.lang = l === 'zh' ? 'zh-CN' : l;
},
t(path) {
const dict = dicts[this.locale] || en;
const parts = String(path).split('.');
let node = dict;
for (const p of parts) {
node = node?.[p];
if (node === undefined) break;
}
if (typeof node === 'string') return node;
// fallback to English, then to the key itself
let fb = en;
for (const p of parts) fb = fb?.[p];
return typeof fb === 'string' ? fb : path;
},
},
});
+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);
},
},
});
+52
View File
@@ -0,0 +1,52 @@
import { defineStore } from 'pinia';
import api from '../api';
export function formatDuration(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds || 0));
const h = String(Math.floor(s / 3600)).padStart(2, '0');
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const sec = String(s % 60).padStart(2, '0');
return `${h}:${m}:${sec}`;
}
export const useTimeStore = defineStore('time', {
state: () => ({
entries: [],
open: null, // running timer entry (ended_at == null)
workspaceId: null,
}),
getters: {
elapsed: (s) => {
if (!s.open) return 0;
return (Date.now() - new Date(s.open.started_at).getTime()) / 1000;
},
},
actions: {
async fetch(workspaceId) {
this.workspaceId = workspaceId;
const { data } = await api.get(`/time/by-workspace/${workspaceId}`);
this.entries = data.entries;
this.open = data.open;
},
async start(description = '', book_id = null) {
const { data } = await api.post('/time/start', {
workspace_id: this.workspaceId, description, book_id,
});
this.open = data.entry;
this.entries.unshift(data.entry);
return data.entry;
},
async stop() {
const { data } = await api.post('/time/stop', { workspace_id: this.workspaceId });
this.open = null;
const idx = this.entries.findIndex((e) => e.id === data.entry.id);
if (idx >= 0) this.entries[idx] = { ...data.entry, user_name: this.entries[idx].user_name };
else this.entries.unshift(data.entry);
return data.entry;
},
async remove(id) {
await api.delete(`/time/${id}`);
this.entries = this.entries.filter((e) => e.id !== id);
},
},
});
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia';
import api from '../api';
// Global UI state: settings window visibility.
export const useUiStore = defineStore('ui', {
state: () => ({
settingsOpen: false,
settingsTab: 'profile',
}),
actions: {
openSettings(tab = 'profile') {
this.settingsTab = tab;
this.settingsOpen = true;
},
closeSettings() {
this.settingsOpen = false;
},
},
});
+21 -1
View File
@@ -6,6 +6,7 @@ export const useWorkspaceStore = defineStore('workspace', {
workspaces: [],
currentId: localStorage.getItem('workspaceId') || null,
pages: [],
books: [],
role: null,
}),
actions: {
@@ -32,9 +33,28 @@ export const useWorkspaceStore = defineStore('workspace', {
this.role = data.role;
return this.pages;
},
async fetchBooks() {
if (!this.currentId) return [];
const { data } = await api.get(`/books/by-workspace/${this.currentId}`);
this.books = data.books;
return this.books;
},
async createBook(title, icon) {
const { data } = await api.post('/books', {
workspace_id: this.currentId, title, icon,
});
this.books.push(data.book);
return data.book;
},
// Tree of top-level pages (book_id == null), nested via parent_id.
tree() {
return this.treeFor(null);
},
// Tree of pages inside one book.
treeFor(bookId) {
const inScope = this.pages.filter((p) => (p.book_id || null) === (bookId || null));
const byParent = new Map();
for (const p of this.pages) {
for (const p of inScope) {
const key = p.parent_id || 'root';
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key).push(p);
+294
View File
@@ -0,0 +1,294 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const L = useLocaleStore();
const ws = useWorkspaceStore();
const tab = ref('overview');
const counts = ref({});
const users = ref([]);
const newUser = reactive({ name: '', email: '', password: '', is_admin: false });
const userMsg = ref('');
const teams = ref([]);
const newTeam = ref('');
const teamMembers = ref({});
const teamWsName = ref({});
const members = ref([]);
const groups = ref([]);
const availPerms = ref([]);
const newGroup = ref('');
const newPerms = ref([]);
const groupMembers = ref({});
async function loadOverview() {
const { data } = await api.get('/admin/overview');
counts.value = data.counts;
}
async function loadUsers() {
const { data } = await api.get('/admin/users');
users.value = data.users;
}
async function createUser() {
userMsg.value = '';
try {
const { data } = await api.post('/admin/users', { ...newUser });
users.value.push({ ...data.user, workspaces: 0 });
newUser.name = '';
newUser.email = '';
newUser.password = '';
newUser.is_admin = false;
userMsg.value = L.t('admin.created');
} catch (e) {
userMsg.value = e.response?.data?.error || 'Error';
}
}
async function setAdmin(u, val) {
const { data } = await api.patch(`/admin/users/${u.id}`, { is_admin: val });
u.is_admin = data.user.is_admin;
}
async function removeUser(u) {
if (!confirm(`${L.t('admin.confirm')} (${u.email})`)) return;
await api.delete(`/admin/users/${u.id}`);
users.value = users.value.filter((x) => x.id !== u.id);
}
async function loadTeams() {
if (!ws.currentId) return;
const { data } = await api.get(`/teams/by-workspace/${ws.currentId}`);
teams.value = data.teams;
const m = await api.get(`/workspaces/${ws.currentId}/members`);
members.value = m.data.members;
}
async function createTeam() {
if (!newTeam.value.trim()) return;
const { data } = await api.post('/teams', { workspace_id: ws.currentId, name: newTeam.value.trim() });
teams.value.push(data.team);
newTeam.value = '';
}
async function removeTeam(t) {
if (!confirm('✕?')) return;
await api.delete(`/teams/${t.id}`);
teams.value = teams.value.filter((x) => x.id !== t.id);
}
async function showTeamMembers(t) {
const { data } = await api.get(`/teams/${t.id}/members`);
teamMembers.value[t.id] = data.members;
}
async function addTeamMember(t, userId, role) {
if (!userId) return;
await api.post(`/teams/${t.id}/members`, { user_id: userId, role });
showTeamMembers(t);
}
async function removeTeamMember(t, userId) {
await api.delete(`/teams/${t.id}/members/${userId}`);
showTeamMembers(t);
}
async function makeTeamWs(t) {
const name = teamWsName.value[t.id]?.trim();
if (!name) return;
const { data } = await api.post(`/teams/${t.id}/workspace`, { name });
teamWsName.value[t.id] = '';
await ws.fetchWorkspaces();
alert(`✓ ${data.workspace.name} · TEAM_${t.name}`);
}
async function loadGroups() {
if (!ws.currentId) return;
const { data } = await api.get(`/groups/by-workspace/${ws.currentId}`);
groups.value = data.groups;
availPerms.value = data.availablePermissions;
}
async function createGroup() {
if (!newGroup.value.trim()) return;
const { data } = await api.post('/groups', {
workspace_id: ws.currentId, name: newGroup.value.trim(), permissions: newPerms.value,
});
groups.value.push(data.group);
newGroup.value = '';
newPerms.value = [];
}
async function togglePerm(g, perm) {
const perms = new Set(g.permissions || []);
if (perms.has(perm)) perms.delete(perm);
else perms.add(perm);
const { data } = await api.patch(`/groups/${g.id}`, { permissions: [...perms] });
g.permissions = data.group.permissions;
}
async function removeGroup(g) {
if (!confirm('✕?')) return;
await api.delete(`/groups/${g.id}`);
groups.value = groups.value.filter((x) => x.id !== g.id);
}
async function showGroupMembers(g) {
const { data } = await api.get(`/groups/${g.id}/members`);
groupMembers.value[g.id] = data.members;
}
async function addGroupMember(g, userId) {
if (!userId) return;
await api.post(`/groups/${g.id}/members`, { user_id: userId });
showGroupMembers(g);
}
async function removeGroupMember(g, userId) {
await api.delete(`/groups/${g.id}/members/${userId}`);
showGroupMembers(g);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await loadOverview();
await loadUsers();
await loadTeams();
await loadGroups();
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<h2><Icon name="shield" :size="22" /> {{ L.t('admin.title') }}</h2>
<div class="row wrap admin-tabs">
<button :class="{ ghost: tab !== 'overview' }" @click="tab = 'overview'">{{ L.t('admin.overview') }}</button>
<button :class="{ ghost: tab !== 'users' }" @click="tab = 'users'">{{ L.t('admin.users') }}</button>
<button :class="{ ghost: tab !== 'teams' }" @click="tab = 'teams'"><Icon name="users" :size="16" /> {{ L.t('teams.title') }}</button>
<button :class="{ ghost: tab !== 'groups' }" @click="tab = 'groups'"><Icon name="key" :size="16" /> {{ L.t('groups.title') }}</button>
</div>
<div v-if="tab === 'overview'">
<div class="stat-grid">
<div class="card stat">👥<strong>{{ counts.users }}</strong>{{ L.t('admin.users') }}</div>
<div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>Workspaces</div>
<div class="card stat">📚<strong>{{ counts.books }}</strong>{{ L.t('admin.books') }}</div>
<div class="card stat">📄<strong>{{ counts.pages }}</strong>{{ L.t('admin.pages') }}</div>
<div class="card stat">✅<strong>{{ counts.tasks }}</strong>{{ L.t('admin.tasks') }}</div>
<div class="card stat">⏱️<strong>{{ counts.timeEntries }}</strong>{{ L.t('admin.time') }}</div>
</div>
</div>
<div v-if="tab === 'users'">
<div class="card" style="margin-bottom: 12px">
<h4>+ {{ L.t('admin.createUser') }}</h4>
<div class="row wrap">
<input v-model="newUser.name" :placeholder="L.t('admin.name')" />
<input v-model="newUser.email" type="email" :placeholder="L.t('admin.email')" />
<input v-model="newUser.password" type="password" :placeholder="L.t('admin.newPw')" />
<label class="row"><input type="checkbox" v-model="newUser.is_admin" style="width: auto" /> {{ L.t('admin.admin') }}</label>
<button @click="createUser">{{ L.t('admin.create') }}</button>
</div>
<p v-if="userMsg" class="muted">{{ userMsg }}</p>
</div>
<div class="card table-scroll">
<table class="data-table">
<thead>
<tr>
<th></th><th>{{ L.t('admin.name') }}</th><th>{{ L.t('admin.email') }}</th>
<th>{{ L.t('admin.role') }}</th><th>{{ L.t('admin.workspaces') }}</th>
<th>{{ L.t('admin.joined') }}</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><Avatar :name="u.name" :size="24" /></td>
<td><strong>{{ u.name }}</strong></td>
<td class="muted">{{ u.email }}</td>
<td>{{ u.is_admin ? `⭐ ${L.t('admin.admin')}` : L.t('admin.user') }}</td>
<td>{{ u.workspaces }}</td>
<td class="muted">{{ new Date(u.created_at).toLocaleDateString() }}</td>
<td class="row">
<button v-if="!u.is_admin" class="ghost mini" @click="setAdmin(u, true)">{{ L.t('admin.makeAdmin') }}</button>
<button v-else class="ghost mini" @click="setAdmin(u, false)">{{ L.t('admin.unmake') }}</button>
<button class="ghost mini" @click="removeUser(u)">✕</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab === 'teams'">
<div class="card" style="margin-bottom: 12px">
<h4>+ {{ L.t('teams.new') }}</h4>
<div class="row">
<input v-model="newTeam" :placeholder="L.t('teams.ph')" @keyup.enter="createTeam" />
<button @click="createTeam">{{ L.t('c.create') }}</button>
</div>
</div>
<p v-if="!teams.length" class="muted">{{ L.t('teams.noTeams') }}</p>
<div v-for="t in teams" :key="t.id" class="card" style="margin-bottom: 10px">
<div class="row" style="justify-content: space-between">
<strong>{{ t.icon }} {{ t.name }} <span class="muted">({{ t.members }})</span></strong>
<button class="ghost mini" @click="removeTeam(t)">✕</button>
</div>
<div class="row wrap" style="margin-top: 8px">
<button class="ghost mini" @click="showTeamMembers(t)">👥 {{ L.t('teams.members') }}</button>
<input v-model="teamWsName[t.id]" :placeholder="L.t('teams.wsName')" style="max-width: 220px" />
<button class="ghost mini" @click="makeTeamWs(t)">🏠 {{ L.t('teams.makeWs') }}</button>
</div>
<div v-if="teamMembers[t.id]?.length" style="margin-top: 8px">
<div v-for="m in teamMembers[t.id]" :key="m.id" class="row member-row">
<Avatar :name="m.name" :avatar="m.avatar" :size="24" />
<span style="flex: 1">{{ m.name }}</span>
<span class="pill">{{ m.role }}</span>
<button class="ghost mini" @click="removeTeamMember(t, m.id)">✕</button>
</div>
</div>
<div class="row" style="margin-top: 8px">
<select @change="addTeamMember(t, $event.target.value, 'member'); $event.target.value = ''" style="max-width: 200px">
<option value="">{{ L.t('teams.pickUser') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
</div>
</div>
<div v-if="tab === 'groups'">
<div class="card" style="margin-bottom: 12px">
<h4>+ {{ L.t('groups.new') }}</h4>
<div class="row wrap">
<input v-model="newGroup" :placeholder="L.t('groups.ph')" @keyup.enter="createGroup" style="flex: 1" />
<button @click="createGroup">{{ L.t('c.create') }}</button>
</div>
<div class="row wrap" style="margin-top: 8px">
<label v-for="p in availPerms" :key="p" class="row perm-check">
<input type="checkbox" :value="p" v-model="newPerms" style="width: auto" /> <code>{{ p }}</code>
</label>
</div>
</div>
<div v-for="g in groups" :key="g.id" class="card" style="margin-bottom: 10px">
<div class="row" style="justify-content: space-between">
<strong><Icon name="key" :size="14" /> {{ g.name }} <span class="muted">({{ g.members }})</span></strong>
<button class="ghost mini" @click="removeGroup(g)">✕</button>
</div>
<div class="muted">{{ L.t('groups.perms') }}:</div>
<div class="row wrap">
<label v-for="p in availPerms" :key="p" class="row perm-check">
<input type="checkbox" :checked="(g.permissions || []).includes(p)" @change="togglePerm(g, p)" style="width: auto" /> <code>{{ p }}</code>
</label>
</div>
<div class="row" style="margin-top: 8px">
<button class="ghost mini" @click="showGroupMembers(g)">👥 {{ L.t('groups.members') }}</button>
<select @change="addGroupMember(g, $event.target.value); $event.target.value = ''" style="max-width: 200px">
<option value="">{{ L.t('teams.pickUser') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div v-if="groupMembers[g.id]?.length" style="margin-top: 8px">
<div v-for="m in groupMembers[g.id]" :key="m.id" class="row member-row">
<Avatar :name="m.name" :avatar="m.avatar" :size="24" />
<span style="flex: 1">{{ m.name }}</span>
<button class="ghost mini" @click="removeGroupMember(g, m.id)">✕</button>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
+107
View File
@@ -0,0 +1,107 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Icon from '../components/Icon.vue';
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const cursor = ref(new Date());
cursor.value.setDate(1);
const tasks = ref([]);
const year = computed(() => cursor.value.getFullYear());
const month = computed(() => cursor.value.getMonth());
const monthName = computed(() =>
cursor.value.toLocaleDateString(L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US', {
month: 'long', year: 'numeric',
}),
);
const cells = computed(() => {
const first = new Date(year.value, month.value, 1);
const startDay = (first.getDay() + 6) % 7; // Monday first
const days = new Date(year.value, month.value + 1, 0).getDate();
const arr = [];
for (let i = 0; i < startDay; i++) arr.push(null);
for (let d = 1; d <= days; d++) arr.push(new Date(year.value, month.value, d));
return arr;
});
function dayKey(d) {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
const byDay = computed(() => {
const map = {};
for (const t of tasks.value) {
if (!t.due_at) continue;
const d = new Date(t.due_at);
const k = dayKey(d);
if (!map[k]) map[k] = [];
map[k].push(t);
}
return map;
});
function isToday(d) {
const n = new Date();
return d && d.toDateString() === n.toDateString();
}
async function load() {
if (!ws.currentId) return;
const from = new Date(year.value, month.value, 1).toISOString();
const to = new Date(year.value, month.value + 1, 0, 23, 59).toISOString();
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params: { from, to } });
tasks.value = data.tasks;
}
function prev() { cursor.value = new Date(year.value, month.value - 1, 1); }
function next() { cursor.value = new Date(year.value, month.value + 1, 1); }
function goToday() { const n = new Date(); cursor.value = new Date(n.getFullYear(), n.getMonth(), 1); }
watch(cursor, load);
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await load();
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<div class="topbar">
<h2 style="margin: 0"><Icon name="calendar" :size="22" /> {{ L.t('cal.title') }} — {{ monthName }}</h2>
<div class="row">
<button class="ghost" @click="prev">◀</button>
<button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button>
<button class="ghost" @click="next">▶</button>
</div>
</div>
<div class="calendar">
<div class="cal-head" v-for="d in ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']" :key="d">{{ d }}</div>
<div v-for="(d, i) in cells" :key="i" class="cal-cell" :class="{ today: isToday(d) }">
<template v-if="d">
<div class="cal-day">{{ d.getDate() }}</div>
<div
v-for="t in (byDay[dayKey(d)] || [])" :key="t.id"
class="cal-task" :class="{ done: t.status === 'done' }"
:title="t.title"
@click="router.push('/tasks')"
>
{{ t.status === 'done' ? '✅' : '▸' }} {{ t.title }}
</div>
</template>
</div>
</div>
<p class="muted">▸ {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p>
</main>
</div>
</template>
+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>
+90 -79
View File
@@ -1,27 +1,22 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace';
import PageTree from '../components/PageTree.vue';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import GreetingHeader from '../components/GreetingHeader.vue';
import InviteModal from '../components/InviteModal.vue';
import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const auth = useAuthStore();
const ws = useWorkspaceStore();
const router = useRouter();
const newWs = ref('');
const newPage = ref('');
const search = ref('');
const results = ref([]);
const L = useLocaleStore();
const members = ref([]);
async function init() {
await auth.fetchMe().catch(() => {});
await ws.fetchWorkspaces();
await ws.fetchPages();
fetchMembers();
}
const permRows = ref([]);
const showPerms = ref(false);
const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
async function fetchMembers() {
if (!ws.currentId) return;
@@ -29,78 +24,94 @@ async function fetchMembers() {
members.value = data.members;
}
async function switchWs(id) {
ws.setCurrent(id);
async function fetchPerms() {
if (!['owner', 'admin'].includes(ws.role)) return;
try {
const { data } = await api.get(`/workspaces/${ws.currentId}/permissions`);
permRows.value = data.members;
} catch {
permRows.value = [];
}
}
async function setPerm(userId, perm, mode) {
await api.patch(`/workspaces/${ws.currentId}/permissions`, {
user_id: userId, permission: perm, mode,
});
await fetchPerms();
}
function overrideOf(row, perm) {
const o = row.overrides || {};
if (!(perm in o)) return 'inherit';
return o[perm] ? 'allow' : 'deny';
}
const currentName = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.name || '');
const currentTeam = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.team_name || '');
onMounted(async () => {
await auth.fetchMe().catch(() => {});
await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
fetchMembers();
}
async function createWs() {
if (!newWs.value.trim()) return;
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await ws.fetchPages();
fetchMembers();
}
async function createPage(parentId = null) {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', { workspace_id: ws.currentId, parent_id: parentId, title });
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
}
async function doSearch() {
if (!search.value.trim()) { results.value = []; return; }
const { data } = await api.get('/pages/search/query', { params: { q: search.value, workspace_id: ws.currentId } });
results.value = data.results;
}
function logout() {
auth.logout();
router.push('/login');
}
onMounted(init);
await fetchMembers();
await fetchPerms();
});
watch(
() => ws.currentId,
async () => {
await fetchMembers();
await fetchPerms();
},
);
</script>
<template>
<div class="layout">
<aside class="sidebar">
<div class="row" style="justify-content: space-between">
<strong>{{ auth.user?.name }}'s notes</strong>
<button class="ghost" @click="logout">Logout</button>
</div>
<select :value="ws.currentId" @change="switchWs($event.target.value)">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
</select>
<div class="row">
<input v-model="newWs" placeholder="New workspace" />
<button @click="createWs">+</button>
</div>
<input v-model="search" placeholder="Search pages…" @input="doSearch" />
<div v-if="results.length" class="card">
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
{{ r.title }}
</div>
</div>
<div class="row">
<input v-model="newPage" placeholder="New page title" @keyup.enter="createPage()" />
<button @click="createPage()">+</button>
</div>
<PageTree :nodes="ws.tree()" @open="router.push(`/page/${$event}`)" @add-child="createPage($event)" />
<div class="muted">Role: {{ ws.role }} · Members: {{ members.length }}</div>
</aside>
<Sidebar />
<main class="main">
<h2>Welcome, {{ auth.user?.name }}</h2>
<p class="muted">Select a page on the left, or create one. Invite teammates below to collaborate in real time.</p>
<GreetingHeader :name="auth.user?.name" />
<p class="muted">🏠 {{ currentName }} <span v-if="currentTeam" class="pill team-pill">TEAM_{{ currentTeam }}</span> · {{ L.t('side.role') }}: <strong>{{ ws.role }}</strong></p>
<InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" />
<div class="card" style="margin-top: 12px">
<h4>Members</h4>
<div v-for="m in members" :key="m.id" class="row" style="justify-content: space-between; padding: 4px 0">
<span>{{ m.name }} &lt;{{ m.email }}&gt;</span>
<span class="muted">{{ m.role }}</span>
<div class="row" style="justify-content: space-between">
<h4><Icon name="users" :size="16" /> {{ L.t('side.members') }} ({{ members.length }})</h4>
<button
v-if="['owner', 'admin'].includes(ws.role)"
class="ghost mini" @click="showPerms = !showPerms"
>
<Icon name="key" :size="14" /> {{ L.t('perm.manage') }}
</button>
</div>
<div v-for="m in members" :key="m.id" class="row member-row">
<Avatar :name="m.name" :avatar="m.avatar" :size="28" />
<span style="flex: 1">{{ m.name }} <span class="muted">&lt;{{ m.email }}&gt;</span></span>
<span class="pill">{{ m.role }}</span>
</div>
<div v-if="showPerms" class="card table-scroll" style="margin-top: 8px">
<h4><Icon name="key" :size="16" /> {{ L.t('perm.title') }}</h4>
<table class="data-table">
<thead>
<tr><th>{{ L.t('admin.name') }}</th><th v-for="p in PERMS" :key="p"><code>{{ p }}</code></th></tr>
</thead>
<tbody>
<tr v-for="r in permRows" :key="r.user_id">
<td><strong>{{ r.name }}</strong></td>
<td v-for="p in PERMS" :key="p">
<select
:value="overrideOf(r, p)"
@change="setPerm(r.user_id, p, $event.target.value)"
style="max-width: 130px"
>
<option value="inherit">{{ L.t('perm.inherit') }}</option>
<option value="allow">✓ {{ L.t('perm.allow') }}</option>
<option value="deny">✕ {{ L.t('perm.deny') }}</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
+10 -6
View File
@@ -2,6 +2,10 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import Icon from '../components/Icon.vue';
const L = useLocaleStore();
const email = ref('');
const password = ref('');
@@ -22,14 +26,14 @@ async function submit() {
<template>
<div class="auth-wrap card">
<h1>Welcome back</h1>
<p class="muted">Log in to your workspace.</p>
<h1>{{ L.t('auth.title') }}</h1>
<p class="muted">{{ L.t('auth.sub') }}</p>
<form @submit.prevent="submit" style="display: grid; gap: 10px">
<input v-model="email" type="email" placeholder="Email" required />
<input v-model="password" type="password" placeholder="Password" required />
<button type="submit">Login</button>
<input v-model="email" type="email" :placeholder="L.t('auth.email')" required />
<input v-model="password" type="password" :placeholder="L.t('auth.password')" required />
<button type="submit">{{ L.t('auth.btn') }}</button>
</form>
<p v-if="err" style="color: red">{{ err }}</p>
<p class="muted">No account? <router-link to="/register">Register</router-link></p>
<p class="muted"><Icon name="lock" :size="14" /> {{ L.t('auth.locked') }}</p>
</div>
</template>
+40 -25
View File
@@ -3,16 +3,25 @@ import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import api from '../api';
import { connectSocket } from '../socket';
import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace';
import PageTree from '../components/PageTree.vue';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const page = ref(null);
const comments = ref([]);
@@ -26,7 +35,7 @@ let saveTimer = null;
let typingTimer = null;
const editor = useEditor({
extensions: [StarterKit],
extensions: [StarterKit, TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell],
content: '',
editable: true,
onUpdate: ({ editor }) => {
@@ -44,7 +53,6 @@ async function save(ed = editor.value) {
if (!page.value || !canEdit.value || !ed) return;
const content_json = ed.getJSON();
const content_html = ed.getHTML();
// Persist via REST (authoritative) + broadcast via socket for instant sync
await api.patch(`/pages/${page.value.id}`, { content_json, content_html }).catch(() => {});
socket?.emit('page:edit', { pageId: page.value.id, content_json, content_html });
}
@@ -56,7 +64,6 @@ async function load() {
const { data } = await api.get(`/pages/${id}`);
page.value = data.page;
comments.value = data.comments;
canEdit.value = ['owner', 'admin', 'member'].includes(ws.role) || true;
editor.value?.commands.setContent(data.page.content_json?.type ? data.page.content_json : (data.page.content_html || ''));
joinRoom(id);
} catch (e) {
@@ -82,16 +89,18 @@ function joinRoom(pageId) {
});
socket.on('presence:update', ({ members }) => { presence.value = members; });
socket.on('typing', ({ name }) => {
typingUser.value = `${name} is typing…`;
typingUser.value = `✍️ ${name} …`;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => (typingUser.value = ''), 1500);
});
socket.on('comment:added', ({ comment }) => comments.value.push(comment));
}
async function saveTitle() {
async function saveMeta() {
if (!page.value) return;
const { data } = await api.patch(`/pages/${page.value.id}`, { title: page.value.title });
const { data } = await api.patch(`/pages/${page.value.id}`, {
title: page.value.title, book_id: page.value.book_id,
});
page.value = data.page;
ws.fetchPages();
}
@@ -104,7 +113,7 @@ async function addComment() {
}
async function removePage() {
if (!confirm('Delete this page?')) return;
if (!confirm('✕?')) return;
await api.delete(`/pages/${page.value.id}`);
await ws.fetchPages();
router.push('/');
@@ -112,7 +121,10 @@ async function removePage() {
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
if (!ws.pages.length && ws.currentId) await ws.fetchPages();
if (!ws.pages.length && ws.currentId) {
await ws.fetchBooks();
await ws.fetchPages();
}
await load();
});
watch(() => route.params.id, load);
@@ -124,46 +136,49 @@ onUnmounted(() => {
<template>
<div class="layout">
<aside class="sidebar">
<button class="ghost" @click="router.push('/')">← Dashboard</button>
<PageTree :nodes="ws.tree()" :active-id="route.params.id" @open="router.push(`/page/${$event}`)" @add-child="router.push('/')" />
</aside>
<Sidebar :active-page-id="route.params.id" />
<main class="main" v-if="page">
<div class="topbar">
<input v-model="page.title" @change="saveTitle" style="font-size: 22px; font-weight: 700" />
<button class="ghost" @click="removePage">Delete</button>
<div class="topbar wrap">
<input v-model="page.title" @change="saveMeta" style="font-size: 22px; font-weight: 700; flex: 1" />
<select v-model="page.book_id" @change="saveMeta" style="max-width: 200px" :title="L.t('task.book')">
<option :value="null">📄 {{ L.t('task.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<button class="ghost" @click="removePage">✕</button>
</div>
<div class="presence" style="margin-bottom: 8px">
<span class="muted">Online ({{ presence.length }}):</span>
<span v-for="m in presence" :key="m.userId" class="avatar" :title="m.email">{{ (m.name || m.email || '?')[0].toUpperCase() }}</span>
<span class="muted">● ({{ presence.length }}):</span>
<Avatar v-for="m in presence" :key="m.userId" :name="m.name" :size="26" />
<span class="muted" v-if="typingUser">{{ typingUser }}</span>
</div>
<div class="toolbar">
<button class="ghost" @click="editor?.chain().focus().toggleBold().run()">B</button>
<button class="ghost" @click="editor?.chain().focus().toggleBold().run()"><b>B</b></button>
<button class="ghost" @click="editor?.chain().focus().toggleItalic().run()"><i>I</i></button>
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">H1</button>
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• List</button>
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• {{ L.t('side.pages') }}</button>
<button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</button>
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">⊞ Table</button>
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()">&lt;/&gt;</button>
<button @click="save()">Save</button>
<button @click="save()">✓ {{ L.t('c.save') }}</button>
</div>
<EditorContent :editor="editor" class="tiptap" />
<div class="card" style="margin-top: 16px">
<h3>Comments ({{ comments.length }})</h3>
<h3>{{ L.t('com.title') }} ({{ comments.length }})</h3>
<div v-for="c in comments" :key="c.id" class="comment">
<strong>{{ c.author_name || 'Someone' }}</strong>
<span class="muted"> · {{ new Date(c.created_at).toLocaleString() }}</span>
<p>{{ c.body }}</p>
</div>
<div class="row" style="margin-top: 8px">
<input v-model="newComment" placeholder="Write a comment…" @keyup.enter="addComment" />
<button @click="addComment">Post</button>
<input v-model="newComment" :placeholder="L.t('com.ph')" @keyup.enter="addComment" />
<button @click="addComment">{{ L.t('com.post') }}</button>
</div>
</div>
</main>
<main class="main" v-else>
<p v-if="err" style="color: red">{{ err }}</p>
<p v-else class="muted">Loading…</p>
<p v-else class="muted">{{ L.t('c.loading') }}</p>
</main>
</div>
</template>
-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>
+185
View File
@@ -0,0 +1,185 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const tasks = ref([]);
const teams = ref([]);
const members = ref([]);
const teamFilter = ref('');
const title = ref('');
const due = ref('');
const priority = ref('normal');
const bookId = ref('');
const pageId = ref('');
const teamId = ref('');
const assigneeId = ref('');
const cols = computed(() => ({
todo: tasks.value.filter((t) => t.status === 'todo'),
doing: tasks.value.filter((t) => t.status === 'doing'),
done: tasks.value.filter((t) => t.status === 'done'),
}));
function isOverdue(t) {
return t.status !== 'done' && t.due_at && new Date(t.due_at) < new Date();
}
function fmtDate(d) {
return d ? new Date(d).toLocaleString() : L.t('task.noDue');
}
async function load() {
if (!ws.currentId) return;
const params = {};
if (teamFilter.value) params.team_id = teamFilter.value;
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params });
tasks.value = data.tasks;
}
async function loadMeta() {
const { data } = await api.get(`/teams/by-workspace/${ws.currentId}`);
teams.value = data.teams;
const m = await api.get(`/workspaces/${ws.currentId}/members`);
members.value = m.data.members;
}
async function create() {
if (!title.value.trim()) return;
const { data } = await api.post('/tasks', {
workspace_id: ws.currentId,
title: title.value.trim(),
priority: priority.value,
due_at: due.value || null,
book_id: bookId.value || null,
page_id: pageId.value || null,
team_id: teamId.value || null,
assignee_id: assigneeId.value || null,
});
tasks.value.unshift(data.task);
title.value = '';
due.value = '';
bookId.value = '';
pageId.value = '';
teamId.value = '';
assigneeId.value = '';
}
async function setStatus(t, status) {
const { data } = await api.patch(`/tasks/${t.id}`, { status });
Object.assign(t, data.task);
}
async function assign(t, userId) {
const { data } = await api.patch(`/tasks/${t.id}`, { assignee_id: userId || null });
Object.assign(t, data.task);
const m = members.value.find((x) => x.id === data.task.assignee_id);
t.assignee_name = m?.name || null;
t.assignee_avatar = m?.avatar || null;
}
async function remove(t) {
if (!confirm('✕?')) return;
await api.delete(`/tasks/${t.id}`);
tasks.value = tasks.value.filter((x) => x.id !== t.id);
}
watch(teamFilter, load);
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
await loadMeta();
await load();
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<h2><Icon name="tasks" :size="22" /> {{ L.t('task.title') }}</h2>
<div class="row wrap team-tabs">
<button :class="{ ghost: teamFilter !== '' }" @click="teamFilter = ''">🌐 {{ L.t('task.allTeams') }}</button>
<button
v-for="tm in teams" :key="tm.id"
:class="{ ghost: teamFilter !== tm.id }"
@click="teamFilter = tm.id"
>
{{ tm.icon }} {{ tm.name }}
</button>
</div>
<div class="card new-task">
<div class="row wrap">
<input v-model="title" :placeholder="L.t('task.ph')" @keyup.enter="create" style="flex: 2" />
<input v-model="due" type="datetime-local" :title="L.t('task.due')" />
<select v-model="priority" style="max-width: 130px">
<option value="low">🟢 {{ L.t('task.low') }}</option>
<option value="normal">🟡 {{ L.t('task.normal') }}</option>
<option value="high">🔴 {{ L.t('task.high') }}</option>
</select>
</div>
<div class="row wrap">
<select v-model="bookId">
<option value="">{{ L.t('task.book') }}: {{ L.t('task.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<select v-model="pageId">
<option value="">{{ L.t('task.page') }}: {{ L.t('task.none') }}</option>
<option v-for="p in ws.pages" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
<select v-model="teamId">
<option value="">{{ L.t('task.team') }}: {{ L.t('task.none') }}</option>
<option v-for="tm in teams" :key="tm.id" :value="tm.id">{{ tm.icon }} {{ tm.name }}</option>
</select>
<select v-model="assigneeId">
<option value="">{{ L.t('task.assignee') }}: {{ L.t('task.none') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
<button @click="create">+ {{ L.t('task.new') }}</button>
</div>
</div>
<div class="kanban">
<div v-for="col in ['todo', 'doing', 'done']" :key="col" class="kanban-col">
<h3>{{ L.t(`task.${col}`) }} ({{ cols[col].length }})</h3>
<div v-for="t in cols[col]" :key="t.id" class="card task-card" :class="{ overdue: isOverdue(t) }">
<strong>{{ t.title }}</strong>
<div class="muted">
{{ t.priority === 'high' ? '🔴' : t.priority === 'low' ? '🟢' : '🟡' }}
🕒 {{ fmtDate(t.due_at) }}
<span v-if="isOverdue(t)" class="overdue-tag">{{ L.t('task.overdue') }}</span>
</div>
<div class="muted">
<span v-if="t.book_title">📚 {{ t.book_title }}</span>
<span v-if="t.page_title" class="link" @click="router.push(`/page/${t.page_id}`)">📄 {{ t.page_title }}</span>
<span v-if="t.team_name" class="pill">👥 {{ t.team_name }}</span>
</div>
<div class="row wrap">
<Avatar v-if="t.assignee_name" :name="t.assignee_name" :avatar="t.assignee_avatar" :size="22" />
<select :value="t.assignee_id || ''" @change="assign(t, $event.target.value)" style="max-width: 150px">
<option value="">{{ L.t('task.assignee') }}</option>
<option v-for="m in members" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div class="row wrap">
<button v-if="t.status !== 'todo'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.toTodo') }}</button>
<button v-if="t.status === 'todo'" class="ghost mini" @click="setStatus(t, 'doing')">{{ L.t('task.toDoing') }}</button>
<button v-if="t.status === 'doing'" class="ghost mini" @click="setStatus(t, 'done')">{{ L.t('task.toDone') }}</button>
<button v-if="t.status === 'done'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.reopen') }}</button>
<button class="ghost mini" @click="remove(t)">✕</button>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useTimeStore, formatDuration } from '../stores/time';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
import Icon from '../components/Icon.vue';
const time = useTimeStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const total = computed(() =>
time.entries.reduce((s, e) => s + (e.duration_seconds || 0), 0),
);
function fmt(s) {
return s ? new Date(s).toLocaleString() : '—';
}
async function remove(id) {
if (!confirm('✕?')) return;
await time.remove(id);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
if (ws.currentId) await time.fetch(ws.currentId);
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<h2><Icon name="timer" :size="22" /> {{ L.t('time.title') }}</h2>
<div v-if="time.open" class="card running-banner">
🔴 {{ L.t('time.running') }} <strong>{{ time.open.description || '…' }}</strong>
<span v-if="time.open.book_id" class="muted">📚</span>
</div>
<div class="card">
<h3>{{ L.t('time.entries') }} · {{ L.t('time.total') }}: {{ formatDuration(total) }}</h3>
<p v-if="!time.entries.length" class="muted">{{ L.t('time.noEntries') }}</p>
<table v-else class="data-table">
<thead>
<tr>
<th>{{ L.t('time.started') }}</th>
<th>{{ L.t('time.ended') }}</th>
<th>{{ L.t('time.dur') }}</th>
<th>{{ L.t('time.book') }}</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="e in time.entries" :key="e.id" :class="{ running: !e.ended_at }">
<td>{{ fmt(e.started_at) }}</td>
<td>{{ e.ended_at ? fmt(e.ended_at) : `🔴 ${L.t('time.running')}` }}</td>
<td><strong>{{ e.duration_seconds != null ? formatDuration(e.duration_seconds) : '…' }}</strong></td>
<td>{{ e.book_title ? `📚 ${e.book_title}` : L.t('time.none') }}{{ e.description ? ` — ${e.description}` : '' }}</td>
<td><Avatar :name="e.user_name" :size="22" /></td>
<td><button class="ghost mini" @click="remove(e.id)">✕</button></td>
</tr>
</tbody>
</table>
</div>
</main>
</div>
</template>