From fb396d313a5632f925c6a780c2e9ba3f3a1d9baa Mon Sep 17 00:00:00 2001 From: x3 Date: Tue, 22 Sep 2026 13:56:26 +0200 Subject: [PATCH] feat(frontend): settings window, i18n, admin UI, books, tasks, calendar, timer - 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 --- README.md | 12 +- docs/API.md | 26 ++ frontend/package-lock.json | 86 ++++++ frontend/package.json | 6 + frontend/src/App.vue | 33 ++- frontend/src/assets/style.css | 274 ++++++++++++++---- frontend/src/components/Avatar.vue | 14 + frontend/src/components/GreetingHeader.vue | 41 +++ frontend/src/components/InviteModal.vue | 26 +- frontend/src/components/SettingsWindow.vue | 204 +++++++++++++ frontend/src/components/Sidebar.vue | 153 ++++++++++ frontend/src/components/ThemeToggle.vue | 12 - frontend/src/components/TimeTrackerButton.vue | 66 +++++ frontend/src/components/UserSearch.vue | 53 ++++ frontend/src/i18n/de.js | 61 ++++ frontend/src/i18n/en.js | 61 ++++ frontend/src/i18n/zh.js | 61 ++++ frontend/src/main.js | 2 + frontend/src/router/index.js | 9 + frontend/src/stores/auth.js | 9 + frontend/src/stores/locale.js | 34 +++ frontend/src/stores/time.js | 52 ++++ frontend/src/stores/ui.js | 19 ++ frontend/src/stores/workspace.js | 22 +- frontend/src/views/Admin.vue | 83 ++++++ frontend/src/views/Calendar.vue | 106 +++++++ frontend/src/views/Dashboard.vue | 101 ++----- frontend/src/views/PageEditor.vue | 65 +++-- frontend/src/views/Tasks.vue | 128 ++++++++ frontend/src/views/Time.vue | 69 +++++ 30 files changed, 1698 insertions(+), 190 deletions(-) create mode 100644 frontend/src/components/Avatar.vue create mode 100644 frontend/src/components/GreetingHeader.vue create mode 100644 frontend/src/components/SettingsWindow.vue create mode 100644 frontend/src/components/Sidebar.vue delete mode 100644 frontend/src/components/ThemeToggle.vue create mode 100644 frontend/src/components/TimeTrackerButton.vue create mode 100644 frontend/src/components/UserSearch.vue create mode 100644 frontend/src/i18n/de.js create mode 100644 frontend/src/i18n/en.js create mode 100644 frontend/src/i18n/zh.js create mode 100644 frontend/src/stores/locale.js create mode 100644 frontend/src/stores/time.js create mode 100644 frontend/src/stores/ui.js create mode 100644 frontend/src/views/Admin.vue create mode 100644 frontend/src/views/Calendar.vue create mode 100644 frontend/src/views/Tasks.vue create mode 100644 frontend/src/views/Time.vue diff --git a/README.md b/README.md index ffc40c7..c919df5 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,16 @@ 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/done), priorities, deadlines, links to books/pages. +- **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. diff --git a/docs/API.md b/docs/API.md index 9deea8e..e879885 100644 --- a/docs/API.md +++ b/docs/API.md @@ -59,6 +59,32 @@ 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`, `PATCH /admin/users/:id {is_admin}`, `DELETE /admin/users/:id` ## Health diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b880691..603c6a5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index bca62fd..b62ed2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1205c91..6eddb08 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,17 +1,40 @@ diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 6c304b7..9ea51e5 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -1,93 +1,247 @@ :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; } + +@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; } +} diff --git a/frontend/src/components/Avatar.vue b/frontend/src/components/Avatar.vue new file mode 100644 index 0000000..103fd1b --- /dev/null +++ b/frontend/src/components/Avatar.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/GreetingHeader.vue b/frontend/src/components/GreetingHeader.vue new file mode 100644 index 0000000..0f838ee --- /dev/null +++ b/frontend/src/components/GreetingHeader.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/components/InviteModal.vue b/frontend/src/components/InviteModal.vue index 54404a9..95f0e9f 100644 --- a/frontend/src/components/InviteModal.vue +++ b/frontend/src/components/InviteModal.vue @@ -1,21 +1,33 @@ + + diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue new file mode 100644 index 0000000..2c628de --- /dev/null +++ b/frontend/src/components/Sidebar.vue @@ -0,0 +1,153 @@ + + + diff --git a/frontend/src/components/ThemeToggle.vue b/frontend/src/components/ThemeToggle.vue deleted file mode 100644 index 327b398..0000000 --- a/frontend/src/components/ThemeToggle.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/frontend/src/components/TimeTrackerButton.vue b/frontend/src/components/TimeTrackerButton.vue new file mode 100644 index 0000000..b6e6ea3 --- /dev/null +++ b/frontend/src/components/TimeTrackerButton.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/components/UserSearch.vue b/frontend/src/components/UserSearch.vue new file mode 100644 index 0000000..b323a93 --- /dev/null +++ b/frontend/src/components/UserSearch.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/i18n/de.js b/frontend/src/i18n/de.js new file mode 100644 index 0000000..3c67660 --- /dev/null +++ b/frontend/src/i18n/de.js @@ -0,0 +1,61 @@ +export default { + nav: { + dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung', + admin: 'Admin', logout: 'Abmelden', + }, + side: { + workspaces: 'Workspaces', newWorkspace: 'Neuer Workspace', searchPh: 'Seiten suchen …', + newPagePh: 'Neue Seite', newBookPh: 'Neues Book', books: '📚 Books', pages: '📄 Seiten', + members: 'Mitglieder', role: 'Rolle', + }, + 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)', 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', + 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', + }, + 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 …', + }, + com: { title: 'Kommentare', ph: 'Kommentar schreiben …', post: 'Posten' }, +}; diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js new file mode 100644 index 0000000..637c165 --- /dev/null +++ b/frontend/src/i18n/en.js @@ -0,0 +1,61 @@ +export default { + nav: { + dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking', + admin: 'Admin', logout: 'Log out', + }, + side: { + workspaces: 'Workspaces', newWorkspace: 'New workspace', searchPh: 'Search pages …', + newPagePh: 'New page', newBookPh: 'New book', books: '📚 Books', pages: '📄 Pages', + members: 'Members', role: 'Role', + }, + 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', 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', + 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', + }, + 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 …', + }, + com: { title: 'Comments', ph: 'Write a comment …', post: 'Post' }, +}; diff --git a/frontend/src/i18n/zh.js b/frontend/src/i18n/zh.js new file mode 100644 index 0000000..f1af06f --- /dev/null +++ b/frontend/src/i18n/zh.js @@ -0,0 +1,61 @@ +export default { + nav: { + dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录', + admin: '管理', logout: '退出登录', + }, + side: { + workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …', + newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面', + members: '成员', role: '角色', + }, + 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: '模糊背景', close: '关闭', + presetNone: '无背景', presetCustom: '自定义链接', + }, + task: { + title: '任务', new: '新建任务', ph: '要做什么?', + todo: '📝 待办', doing: '🚧 进行中', done: '✅ 已完成', + priority: '优先级', low: '低', normal: '中', high: '高', + due: '截止', noDue: '无截止日期', book: '书册', page: '页面', + 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: '时间记录', + }, + c: { + save: '保存', cancel: '取消', delete: '删除', create: '创建', + back: '返回', loading: '加载中 …', + }, + inv: { + title: '✉️ 邀请协作者', hint: '输入搜索,或填写完整邮箱。', + ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请', + pick: '选择人员 …', + }, + com: { title: '评论', ph: '写评论 …', post: '发送' }, +}; diff --git a/frontend/src/main.js b/frontend/src/main.js index a74a0a9..e86dc10 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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(); diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index a48efc5..d6fd5bd 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -6,6 +6,10 @@ 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 Admin from '../views/Admin.vue'; const routes = [ { path: '/setup', component: Setup }, @@ -13,6 +17,10 @@ const routes = [ { 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: '/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; }); diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 7ad7b10..cf98d29 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -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; diff --git a/frontend/src/stores/locale.js b/frontend/src/stores/locale.js new file mode 100644 index 0000000..d76403a --- /dev/null +++ b/frontend/src/stores/locale.js @@ -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; + }, + }, +}); diff --git a/frontend/src/stores/time.js b/frontend/src/stores/time.js new file mode 100644 index 0000000..2df944b --- /dev/null +++ b/frontend/src/stores/time.js @@ -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); + }, + }, +}); diff --git a/frontend/src/stores/ui.js b/frontend/src/stores/ui.js new file mode 100644 index 0000000..798debf --- /dev/null +++ b/frontend/src/stores/ui.js @@ -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; + }, + }, +}); diff --git a/frontend/src/stores/workspace.js b/frontend/src/stores/workspace.js index 61409ec..6f0d536 100644 --- a/frontend/src/stores/workspace.js +++ b/frontend/src/stores/workspace.js @@ -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); diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue new file mode 100644 index 0000000..4727a09 --- /dev/null +++ b/frontend/src/views/Admin.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/views/Calendar.vue b/frontend/src/views/Calendar.vue new file mode 100644 index 0000000..6e7c54c --- /dev/null +++ b/frontend/src/views/Calendar.vue @@ -0,0 +1,106 @@ + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 1effe97..f6b15af 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,106 +1,49 @@