-
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 @@
+
+
+
+
+
+ {{ avatar }}
+ {{ letter }}
+
+
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 @@
+
+
+
+
+
+
{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!
+
📅 {{ date }}
+
+
🕒 {{ clock }}
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
⚙️ {{ L.t('set.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ e }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
👀 {{ L.t('set.theme') }}: {{ theme.resolved }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ key === 'none' ? '🚫' : '' }}
+
+
+
+
+
{{ L.t('set.bgUrlHint') }}
+
+
+
+
+ {{ msg }}
+ {{ err }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
{{ L.t('c.loading') }}
+
+
+
{{ u.name }}
{{ u.email }}
+
+
∅
+
+
+
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 @@
+
+
+
+
+
+
+ 🛡️ {{ L.t('admin.title') }}
+ {{ L.t('admin.overview') }}
+
+
👥{{ counts.users }}{{ L.t('admin.users') }}
+
🏠{{ counts.workspaces }}{{ L.t('nav.dashboard') }}
+
📚{{ counts.books }}{{ L.t('admin.books') }}
+
📄{{ counts.pages }}{{ L.t('admin.pages') }}
+
✅{{ counts.tasks }}{{ L.t('admin.tasks') }}
+
⏱️{{ counts.timeEntries }}{{ L.t('admin.time') }}
+
+ {{ L.t('admin.users') }} ({{ users.length }})
+
+
+
+
+ |
+ {{ L.t('admin.name') }} |
+ {{ L.t('admin.email') }} |
+ {{ L.t('admin.role') }} |
+ {{ L.t('admin.workspaces') }} |
+ {{ L.t('admin.joined') }} |
+ |
+
+
+
+
+ |
+ {{ u.name }} |
+ {{ u.email }} |
+ {{ u.is_admin ? `⭐ ${L.t('admin.admin')}` : L.t('admin.user') }} |
+ {{ u.workspaces }} |
+ {{ new Date(u.created_at).toLocaleDateString() }} |
+
+
+
+
+ |
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
🗓️ {{ L.t('cal.title') }} — {{ monthName }}
+
+
+
+
+
+
+
+
{{ d }}
+
+
+ {{ d.getDate() }}
+
+ {{ t.status === 'done' ? '✅' : '📌' }} {{ t.title }}
+
+
+
+
+ 📌 {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨
+
+
+
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 @@
-
+
- Welcome, {{ auth.user?.name }}
- Select a page on the left, or create one. Invite teammates below to collaborate in real time.
+
+ 🏠 {{ currentName }} · {{ L.t('side.role') || ws.role }}: {{ ws.role }}
-
Members
-
-
{{ m.name }} <{{ m.email }}>
-
{{ m.role }}
+
👥 {{ L.t('side.members') }} ({{ members.length }})
+
+
+
{{ m.name }} <{{ m.email }}>
+
{{ m.role }}
diff --git a/frontend/src/views/PageEditor.vue b/frontend/src/views/PageEditor.vue
index 78603f5..85255c9 100644
--- a/frontend/src/views/PageEditor.vue
+++ b/frontend/src/views/PageEditor.vue
@@ -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(() => {
-
+
-
-
-
+
+
+
+
-
Online ({{ presence.length }}):
-
{{ (m.name || m.email || '?')[0].toUpperCase() }}
+
🟢 ({{ presence.length }}):
+
{{ typingUser }}
-
+
-
+
+
+
-
+
{{ err }}
- Loading…
+ {{ L.t('c.loading') }}
diff --git a/frontend/src/views/Tasks.vue b/frontend/src/views/Tasks.vue
new file mode 100644
index 0000000..73af810
--- /dev/null
+++ b/frontend/src/views/Tasks.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+ ✅ {{ L.t('task.title') }}
+
+
+
+
{{ L.t(`task.${col}`) }} ({{ cols[col].length }})
+
+
{{ t.title }}
+
+ {{ t.priority === 'high' ? '🔴' : t.priority === 'low' ? '🟢' : '🟡' }}
+ 🕒 {{ fmtDate(t.due_at) }}
+ {{ L.t('task.overdue') }}
+
+
+ 📚 {{ t.book_title }}
+ 📄 {{ t.page_title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/Time.vue b/frontend/src/views/Time.vue
new file mode 100644
index 0000000..622896e
--- /dev/null
+++ b/frontend/src/views/Time.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+ ⏱️ {{ L.t('time.title') }}
+
+ 🔴 {{ L.t('time.running') }} {{ time.open.description || '…' }}
+ 📚
+
+
+
{{ L.t('time.entries') }} · {{ L.t('time.total') }}: {{ formatDuration(total) }}
+
{{ L.t('time.noEntries') }}
+
+
+
+ | {{ L.t('time.started') }} |
+ {{ L.t('time.ended') }} |
+ {{ L.t('time.dur') }} |
+ {{ L.t('time.book') }} |
+ |
+ |
+
+
+
+
+ | {{ fmt(e.started_at) }} |
+ {{ e.ended_at ? fmt(e.ended_at) : `🔴 ${L.t('time.running')}` }} |
+ {{ e.duration_seconds != null ? formatDuration(e.duration_seconds) : '…' }} |
+ {{ e.book_title ? `📚 ${e.book_title}` : L.t('time.none') }}{{ e.description ? ` — ${e.description}` : '' }} |
+ |
+ |
+
+
+
+
+
+
+
{{ c.body }}