diff --git a/README.md b/README.md index 8073964..e39d177 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ light/dark mode. Self-hostable via Node.js or Docker Compose. - **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. diff --git a/docs/API.md b/docs/API.md index 821926c..9b2d657 100644 --- a/docs/API.md +++ b/docs/API.md @@ -93,12 +93,20 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML). - `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}`, `DELETE /chat/message/:id` +- `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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1565a97..fbbcbec 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,9 +5,11 @@ import { useThemeStore } from './stores/theme'; 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(); @@ -32,6 +34,7 @@ onMounted(() => { locale.set(locale.locale); theme.apply(); auth.applyPrefs(); + if (auth.isLoggedIn) connectSocket(); }); @@ -41,7 +44,7 @@ onMounted(() => {
- +
diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index f820ce6..aa3582f 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -301,6 +301,45 @@ input:focus, select:focus { outline: 2px solid var(--accent); border-color: tran [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; } diff --git a/frontend/src/components/Bell.vue b/frontend/src/components/Bell.vue index 0bcd2d7..4e0c819 100644 --- a/frontend/src/components/Bell.vue +++ b/frontend/src/components/Bell.vue @@ -5,6 +5,7 @@ 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(); @@ -75,10 +76,10 @@ onUnmounted(() => { diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue new file mode 100644 index 0000000..d631ee8 --- /dev/null +++ b/frontend/src/components/Icon.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/components/InviteModal.vue b/frontend/src/components/InviteModal.vue index b48000e..8c1297c 100644 --- a/frontend/src/components/InviteModal.vue +++ b/frontend/src/components/InviteModal.vue @@ -3,6 +3,7 @@ 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']); @@ -37,7 +38,7 @@ async function invite() { diff --git a/frontend/src/components/TimeTrackerButton.vue b/frontend/src/components/TimeTrackerButton.vue index b6e6ea3..6f4cc76 100644 --- a/frontend/src/components/TimeTrackerButton.vue +++ b/frontend/src/components/TimeTrackerButton.vue @@ -4,6 +4,7 @@ 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(); @@ -49,7 +50,7 @@ async function start() { @click="toggle" :title="L.t('time.title')" > - ⏱️ {{ time.open ? elapsed : L.t('time.start') }} + {{ time.open ? elapsed : L.t('time.start') }}
diff --git a/frontend/src/i18n/de.js b/frontend/src/i18n/de.js index 6c638f9..e52ed6c 100644 --- a/frontend/src/i18n/de.js +++ b/frontend/src/i18n/de.js @@ -11,7 +11,7 @@ export default { auth: { title: 'Willkommen zurück', sub: 'Melde dich an.', email: 'E-Mail', password: 'Passwort', btn: 'Anmelden', - locked: '🔒 Accounts werden vom Admin erstellt.', + locked: 'Accounts werden vom Admin erstellt.', }, bell: { title: 'Mitteilungen', invites: 'Einladungen', notifs: 'Neuigkeiten', @@ -38,17 +38,17 @@ export default { evening: 'Guten Abend', night: 'Gute Nacht', }, set: { - title: 'Einstellungen', profile: '👤 Profil', appearance: '🎨 Darstellung', - language: '🌍 Sprache', background: '🖼️ Hintergrund', + 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', + 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 …', close: 'Schließen', + upload: 'Bild hochladen …', autoSaved: 'Wird sofort übernommen ✓', close: 'Schließen', presetNone: 'Kein Bild', presetCustom: 'Eigene URL', }, task: { @@ -81,9 +81,22 @@ export default { back: 'Zurück', loading: 'Lädt …', }, inv: { - title: '✉️ Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.', + title: 'Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.', ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen', 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' }, }; diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index 91f3900..c8fe9dd 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -11,7 +11,7 @@ export default { auth: { title: 'Welcome back', sub: 'Log in to your workspace.', email: 'Email', password: 'Password', btn: 'Log in', - locked: '🔒 Accounts are created by an admin.', + locked: 'Accounts are created by an admin.', }, bell: { title: 'Notifications', invites: 'Invites', notifs: 'Updates', @@ -38,17 +38,17 @@ export default { evening: 'Good evening', night: 'Good night', }, set: { - title: 'Settings', profile: '👤 Profile', appearance: '🎨 Appearance', - language: '🌍 Language', background: '🖼️ Background', + 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', + 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 …', close: 'Close', + upload: 'Upload image …', autoSaved: 'Applied instantly ✓', close: 'Close', presetNone: 'No image', presetCustom: 'Custom URL', }, task: { @@ -81,9 +81,22 @@ export default { back: 'Back', loading: 'Loading …', }, inv: { - title: '✉️ Invite collaborator', hint: 'Type to search – or enter a full email.', + title: 'Invite collaborator', hint: 'Type to search – or enter a full email.', ph: 'Search name or email …', btn: 'Invite', done: 'Invited', 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' }, }; diff --git a/frontend/src/i18n/zh.js b/frontend/src/i18n/zh.js index e0f7960..9492bf6 100644 --- a/frontend/src/i18n/zh.js +++ b/frontend/src/i18n/zh.js @@ -11,7 +11,7 @@ export default { auth: { title: '欢迎回来', sub: '登录到你的工作区。', email: '电子邮件', password: '密码', btn: '登录', - locked: '🔒 账号由管理员创建。', + locked: '账号由管理员创建。', }, bell: { title: '通知', invites: '邀请', notifs: '动态', @@ -38,17 +38,17 @@ export default { evening: '晚上好', night: '晚安', }, set: { - title: '设置', profile: '👤 个人资料', appearance: '🎨 外观', - language: '🌍 语言', background: '🖼️ 背景', + title: '设置', profile: '个人资料', appearance: '外观', + language: '语言', background: '背景', name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)', avatarHint: '例如 🦊 或 https://…/photo.png', curPw: '当前密码', newPw: '新密码(至少 8 位)', changePw: '修改密码', pwChanged: '密码已修改 ✅', save: '保存', saved: '已保存 ✅', theme: '配色方案', - light: '☀️ 浅色', dark: '🌙 深色', system: '🖥️ 跟随系统', + light: '浅色', dark: '深色', system: '跟随系统', bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:', bgBlur: '模糊背景', bgGlass: '玻璃拟态组件', - upload: '上传图片 …', close: '关闭', + upload: '上传图片 …', autoSaved: '即时生效 ✓', close: '关闭', presetNone: '无背景', presetCustom: '自定义链接', }, task: { @@ -81,9 +81,22 @@ export default { back: '返回', loading: '加载中 …', }, inv: { - title: '✉️ 邀请协作者', hint: '输入搜索,或填写完整邮箱。', + 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: '发送' }, }; diff --git a/frontend/src/socket.js b/frontend/src/socket.js index 821f19f..149351e 100644 --- a/frontend/src/socket.js +++ b/frontend/src/socket.js @@ -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; } diff --git a/frontend/src/stores/presence.js b/frontend/src/stores/presence.js new file mode 100644 index 0000000..e64bfff --- /dev/null +++ b/frontend/src/stores/presence.js @@ -0,0 +1,12 @@ +import { defineStore } from 'pinia'; + +export const usePresenceStore = defineStore('presence', { + state: () => ({ + online: [], + }), + actions: { + isOnline(id) { + return (this.online || []).includes(id); + }, + }, +}); diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index 992e080..6cbc11e 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -5,6 +5,7 @@ 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(); @@ -73,7 +74,7 @@ async function createTeam() { newTeam.value = ''; } async function removeTeam(t) { - if (!confirm('🗑️?')) return; + if (!confirm('✕?')) return; await api.delete(`/teams/${t.id}`); teams.value = teams.value.filter((x) => x.id !== t.id); } @@ -122,7 +123,7 @@ async function togglePerm(g, perm) { g.permissions = data.group.permissions; } async function removeGroup(g) { - if (!confirm('🗑️?')) return; + if (!confirm('✕?')) return; await api.delete(`/groups/${g.id}`); groups.value = groups.value.filter((x) => x.id !== g.id); } @@ -153,12 +154,12 @@ onMounted(async () => {
-

🛡️ {{ L.t('admin.title') }}

+

{{ L.t('admin.title') }}

- - + +
@@ -204,7 +205,7 @@ onMounted(async () => { - + @@ -224,7 +225,7 @@ onMounted(async () => {
{{ t.icon }} {{ t.name }} ({{ t.members }}) - +
@@ -263,8 +264,8 @@ onMounted(async () => {
- 🔑 {{ g.name }} ({{ g.members }}) - + {{ g.name }} ({{ g.members }}) +
{{ L.t('groups.perms') }}:
diff --git a/frontend/src/views/Calendar.vue b/frontend/src/views/Calendar.vue index 6e7c54c..be243d5 100644 --- a/frontend/src/views/Calendar.vue +++ b/frontend/src/views/Calendar.vue @@ -5,6 +5,7 @@ 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(); @@ -77,7 +78,7 @@ onMounted(async () => {
-

🗓️ {{ L.t('cal.title') }} — {{ monthName }}

+

{{ L.t('cal.title') }} — {{ monthName }}

@@ -95,12 +96,12 @@ onMounted(async () => { :title="t.title" @click="router.push('/tasks')" > - {{ t.status === 'done' ? '✅' : '📌' }} {{ t.title }} + {{ t.status === 'done' ? '✅' : '▸' }} {{ t.title }}
-

📌 {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨

+

▸ {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨

diff --git a/frontend/src/views/Chat.vue b/frontend/src/views/Chat.vue index 42cd049..53cca58 100644 --- a/frontend/src/views/Chat.vue +++ b/frontend/src/views/Chat.vue @@ -1,19 +1,45 @@