feat(frontend): bell, chat, uploads, glass, teams UI, fixes
- 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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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: '🌙' };
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user