Files
openStack/frontend/src/components/Bell.vue
T
x3 4fe56265c9
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
feat(frontend): chat tabs, flat SVG icons, upload autosave, per-user rights
- 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
2026-09-22 14:41:42 +02:00

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>