feat(frontend): bell, chat, uploads, glass, teams UI, fixes
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s

- Notification bell with invites (accept/decline refreshes workspaces)
  and unread badge (polling + socket push); team chat view realtime
- Avatar/background uploads in settings + glassmorphism widgets toggle;
  background layer resolves /uploads URLs; full-bleed blurred backdrop
- Sidebar: visible creation errors, working sub-pages (parent_id),
  TEAM badge workspaces, refresh button, chat nav; admin creates users
- Tasks: per-team boards, assignee select with avatars; admin tabs for
  users/teams/groups (team workspaces, permission checkboxes)
- Register view removed (admin-only accounts); login localized;
  prettier emoji set; scaling/overflow + mobile fixes
This commit is contained in:
x3
2026-09-22 14:27:33 +02:00
parent c3c77333db
commit 0bb4e5c1cd
22 changed files with 898 additions and 157 deletions
+9 -2
View File
@@ -1,13 +1,20 @@
<script setup>
import { computed } from 'vue';
import { fileUrl } from '../files';
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
const isUrl = computed(() => (props.avatar || '').startsWith('http'));
const src = computed(() => {
const a = props.avatar || '';
if (a.startsWith('http') || a.startsWith('/uploads') || a.startsWith('data:')) {
return fileUrl(a);
}
return '';
});
const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase());
</script>
<template>
<span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name">
<img v-if="isUrl" :src="avatar" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<img v-if="src" :src="src" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<span v-else-if="avatar">{{ avatar }}</span>
<span v-else>{{ letter }}</span>
</span>
+107
View File
@@ -0,0 +1,107 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { getSocket } from '../socket';
import { useLocaleStore } from '../stores/locale';
import { useWorkspaceStore } from '../stores/workspace';
const L = useLocaleStore();
const ws = useWorkspaceStore();
const router = useRouter();
const open = ref(false);
const invites = ref([]);
const notifs = ref([]);
const unread = ref(0);
let timer = null;
const total = computed(() => invites.value.length + unread.value);
async function load() {
try {
const { data } = await api.get('/invites/mine');
invites.value = data.invites;
const n = await api.get('/notifications/mine');
notifs.value = n.data.notifications;
unread.value = n.data.unread;
} catch { /* offline */ }
}
async function accept(inv) {
await api.post(`/invites/${inv.id}/accept`);
invites.value = invites.value.filter((i) => i.id !== inv.id);
await ws.fetchWorkspaces();
if (ws.currentId) {
await ws.fetchBooks();
await ws.fetchPages();
}
router.push('/');
}
async function decline(inv) {
await api.post(`/invites/${inv.id}/decline`);
invites.value = invites.value.filter((i) => i.id !== inv.id);
}
async function markAll() {
await api.post('/notifications/read-all');
notifs.value.forEach((n) => (n.is_read = true));
unread.value = 0;
}
async function markOne(n) {
await api.patch(`/notifications/${n.id}/read`);
n.is_read = true;
unread.value = Math.max(0, unread.value - 1);
}
function onPush() {
load();
}
onMounted(() => {
load();
timer = setInterval(load, 20000);
const s = getSocket();
s?.on('notification:new', onPush);
});
onUnmounted(() => {
clearInterval(timer);
getSocket()?.off('notification:new', onPush);
});
</script>
<template>
<div class="bell">
<button class="ghost icon-btn bell-btn" @click="open = !open" :title="L.t('bell.title')">
🔔<span v-if="total" class="badge">{{ total }}</span>
</button>
<div v-if="open" class="card bell-pop">
<h4 style="margin: 0 0 8px">🔔 {{ L.t('bell.title') }}</h4>
<div v-if="invites.length">
<div class="side-label">{{ L.t('bell.invites') }}</div>
<div v-for="inv in invites" :key="inv.id" class="card invite-card">
<strong>{{ inv.workspace_name }}</strong>
<div class="muted">{{ L.t('bell.as') }} {{ inv.role }} · {{ inv.inviter_name || '?' }}</div>
<div class="row" style="margin-top: 6px">
<button class="mini" @click="accept(inv)">✓ {{ L.t('bell.accept') }}</button>
<button class="ghost mini" @click="decline(inv)">✕ {{ L.t('bell.decline') }}</button>
</div>
</div>
</div>
<div class="side-label">{{ L.t('bell.notifs') }}</div>
<div v-if="!notifs.length" class="muted">{{ L.t('bell.empty') }}</div>
<div
v-for="n in notifs.slice(0, 12)" :key="n.id"
class="notif" :class="{ unread: !n.is_read }" @click="markOne(n)"
>
<strong>{{ n.title }}</strong>
<div class="muted">{{ n.body }}</div>
</div>
<button v-if="unread" class="ghost mini" style="margin-top: 6px" @click="markAll">
{{ L.t('bell.readAll') }}
</button>
</div>
</div>
</template>
+4 -4
View File
@@ -12,10 +12,10 @@ onUnmounted(() => clearInterval(timer));
const period = computed(() => {
const h = now.value.getHours();
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '🌅' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '🍱' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '☀️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌇' };
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '☀️' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '☕' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '🌤️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌆' };
return { key: 'greet.night', emoji: '🌙' };
});
+1 -1
View File
@@ -25,7 +25,7 @@ async function invite() {
if (!target) return;
try {
await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
msg.value = `🎉 ${target} → ${role.value}`;
msg.value = `✨ ${target} → ${role.value}`;
email.value = '';
picked.value = null;
emit('invited');
+37 -1
View File
@@ -1,6 +1,7 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import api from '../api';
import { uploadImage } from '../files';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import { useThemeStore } from '../stores/theme';
@@ -19,7 +20,9 @@ const msg = ref('');
const err = ref('');
const bgUrl = ref('');
const bgBlur = ref(true);
const bgGlass = ref(true);
const bgPreset = ref('none');
const uploading = ref('');
const GRADIENTS = {
none: '',
@@ -42,6 +45,7 @@ watch(
([, v]) => v && v === auth.user.bgImage,
)?.[0] || (auth.user.bgImage ? 'custom' : 'none');
bgBlur.value = auth.user.bgBlur !== false;
bgGlass.value = auth.user.bgGlass !== false;
msg.value = '';
err.value = '';
}
@@ -106,13 +110,42 @@ async function saveBackground() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', { bg_image: bgValue(), bg_blur: bgBlur.value });
const { data } = await api.patch('/users/me', {
bg_image: bgValue(), bg_blur: bgBlur.value, bg_glass: bgGlass.value,
});
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function uploadAvatar(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading.value = 'avatar';
try {
form.avatar = await uploadImage(file);
} catch {
err.value = 'Upload failed';
} finally {
uploading.value = '';
}
}
async function uploadBg(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading.value = 'bg';
try {
bgUrl.value = await uploadImage(file);
bgPreset.value = 'custom';
} catch {
err.value = 'Upload failed';
} finally {
uploading.value = '';
}
}
</script>
<template>
@@ -146,6 +179,7 @@ async function saveBackground() {
<div class="emoji-row">
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
</div>
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadAvatar" /> {{ uploading === 'avatar' ? '⏳' : L.t('set.upload') }}</label>
<label>{{ L.t('set.name') }}</label>
<input v-model="form.name" />
<label>{{ L.t('set.email') }}</label>
@@ -190,8 +224,10 @@ async function saveBackground() {
</div>
<label>{{ L.t('set.bgUrl') }}</label>
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadBg" /> {{ uploading === 'bg' ? '⏳' : L.t('set.upload') }}</label>
<p class="muted">{{ L.t('set.bgUrlHint') }}</p>
<label class="row"><input type="checkbox" v-model="bgBlur" style="width: auto" /> {{ L.t('set.bgBlur') }}</label>
<label class="row"><input type="checkbox" v-model="bgGlass" style="width: auto" /> {{ L.t('set.bgGlass') }}</label>
<button @click="saveBackground">{{ L.t('set.save') }}</button>
</div>
+74 -32
View File
@@ -21,6 +21,7 @@ const newPage = ref('');
const newBook = ref('');
const search = ref('');
const results = ref([]);
const sideErr = ref('');
const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}'));
function toggleBook(id) {
@@ -28,9 +29,19 @@ function toggleBook(id) {
localStorage.setItem('collapsedBooks', JSON.stringify(collapsed.value));
}
function fail(e, fallback) {
sideErr.value = e.response?.data?.error || fallback || 'Error';
setTimeout(() => (sideErr.value = ''), 4000);
}
async function refresh() {
await ws.fetchBooks();
await ws.fetchPages();
try {
await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
} catch (e) {
fail(e);
}
}
async function switchWs(id) {
@@ -40,39 +51,60 @@ async function switchWs(id) {
async function createWs() {
if (!newWs.value.trim()) return;
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await refresh();
try {
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await refresh();
} catch (e) {
fail(e);
}
}
async function createPage(bookId = null) {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, title,
});
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
async function createPage(bookId = null, parentId = null) {
try {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, parent_id: parentId, title,
});
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
} catch (e) {
fail(e);
}
}
async function createBook() {
if (!newBook.value.trim()) return;
await ws.createBook(newBook.value.trim(), '📚');
newBook.value = '';
try {
await ws.createBook(newBook.value.trim(), '📚');
newBook.value = '';
} catch (e) {
fail(e);
}
}
async function deleteBook(id) {
if (!confirm('🗑️?')) return;
await api.delete(`/books/${id}`);
await refresh();
try {
await api.delete(`/books/${id}`);
await ws.fetchBooks();
await ws.fetchPages();
} catch (e) {
fail(e);
}
}
async function doSearch() {
if (!search.value.trim()) { results.value = []; return; }
const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId },
});
results.value = data.results;
try {
const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId },
});
results.value = data.results;
} catch {
results.value = [];
}
}
function logout() {
@@ -87,13 +119,20 @@ onMounted(refresh);
<aside class="sidebar">
<div class="user-chip">
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
<strong style="flex: 1">{{ auth.user?.name }}</strong>
<strong style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis">{{ auth.user?.name }}</strong>
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
</div>
<select :value="ws.currentId" @change="switchWs($event.target.value)">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
</select>
<p v-if="sideErr" class="side-error">{{ sideErr }}</p>
<div class="row">
<select :value="ws.currentId" @change="switchWs($event.target.value)" style="flex: 1; min-width: 0">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">
{{ w.name }}{{ w.team_name ? ` · TEAM_${w.team_name}` : '' }} ({{ w.role }})
</option>
</select>
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')">⟳</button>
</div>
<div class="row">
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
<button @click="createWs">+</button>
@@ -104,6 +143,7 @@ onMounted(refresh);
<router-link to="/tasks">✅ {{ L.t('nav.tasks') }}</router-link>
<router-link to="/calendar">🗓️ {{ L.t('nav.calendar') }}</router-link>
<router-link to="/time">⏱️ {{ L.t('nav.time') }}</router-link>
<router-link to="/chat">💬 {{ L.t('nav.chat') }}</router-link>
<router-link v-if="auth.user?.isAdmin" to="/admin">🛡️ {{ L.t('nav.admin') }}</router-link>
</nav>
@@ -121,28 +161,30 @@ onMounted(refresh);
</div>
<div v-for="b in ws.books" :key="b.id" class="book">
<div class="tree-item book-head" @click="toggleBook(b.id)">
<span>{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span>
<button class="ghost mini" @click.stop="createPage(b.id)" title="+">+</button>
<span class="ellipsis">{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span class="row">
<button class="ghost mini" @click.stop="createPage(b.id, null)" title="+">+</button>
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="🗑️">🗑️</button>
</span>
</div>
<div v-if="!collapsed[b.id]" class="book-pages">
<PageTree
:nodes="ws.treeFor(b.id)" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage(b.id)"
@open="router.push(`/page/${$event}`)"
@add-child="createPage(b.id, $event)"
/>
</div>
</div>
<div class="side-label">{{ L.t('side.pages') }}</div>
<div class="row">
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage()" />
<button @click="createPage()">+</button>
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage(null, null)" />
<button @click="createPage(null, null)">+</button>
</div>
<PageTree
:nodes="ws.tree()" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage()"
@open="router.push(`/page/${$event}`)"
@add-child="createPage(null, $event)"
/>
<div class="side-footer">