- Chat rework: Chats (private DMs with user search, online dots, unread badges) and Team chats tabs; shared message component with reactions, edit, image attachments, message search and typing indicators - Flat modern inline SVG icon set (no external dependency) replaces emoji chrome across nav, headers, buttons, tabs and settings - Uploaded avatar/background images apply instantly (auto-save) with confirmation note; per-user permission matrix in dashboard members - Login localized, invite header uses icon, comment header cleaned
109 lines
3.3 KiB
Vue
109 lines
3.3 KiB
Vue
<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';
|
|
import Icon from './Icon.vue';
|
|
|
|
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')">
|
|
<Icon name="bell" /><span v-if="total" class="badge">{{ total }}</span>
|
|
</button>
|
|
<div v-if="open" class="card bell-pop">
|
|
<h4 style="margin: 0 0 8px"><Icon name="bell" :size="16" /> {{ 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>
|