The News page was a hardcoded "under construction" stub, and the Friends/ Alerts header icons did nothing -- both replaced with real Vue pages/ widgets backed by the server's existing news/notifications/community APIs, reached through a generic authenticated REST bridge (community_api.rs: api_get/api_post/api_delete) and a websocket bridge for live chat/presence (community_ws.rs, mirrors the reqwest_websocket pattern remote.rs already uses for the auth-code exchange). - News: GET /api/v1/client/news, rendered with micromark, with real loading/empty/error states. - Alerts: GET/POST /api/v1/notifications/*, polled (not the notifications websocket -- see notifications.ts for why), unread badge + mark one/all read. - Friends: full friends composable (list/requests/search/presence) plus a header dropdown and a /community/friends management page. - Chat: /community/chat, rooms (server/global/title/DM) over the live community websocket with a REST history/read-state load on room switch. - Achievements: per-game card on the library detail page and a per-user summary on the new read-only profile page (/community/profile/:username). - The Community tab's "opens in your system browser" page is gone; /community is now a real in-app hub (activity feed + server list) linking into friends/chat. Also fixes a real auth gap found by actually running the built client against the live server: notifications/community routes are gated by aclManager.getUserIdACL, which only accepts a session cookie or an opaque Bearer APIToken -- never the client's own short-lived signed JWT (generate_authorization_header). The client already had a bridge for this (POST /api/v1/client/user/webtoken, JWT-authenticated, mints an opaque token), but CLIENT_WEBTOKEN_ACLS never granted it the notifications/ community scopes, so the exchange succeeded and the minted token still got 403'd on every one of these routes. Extended that ACL list (04.auth-init.ts) and switched community_api.rs/community_ws.rs to mint and use that webtoken instead of the JWT for these specific calls. Client version bumped to 0.4.2 (tauri.conf.json).
95 lines
3.4 KiB
Vue
95 lines
3.4 KiB
Vue
<template>
|
|
<Menu as="div" class="relative inline-block">
|
|
<MenuButton>
|
|
<HeaderWidget :notifications="unreadCount > 0 ? unreadCount : undefined">
|
|
<BellIcon class="h-5" />
|
|
</HeaderWidget>
|
|
</MenuButton>
|
|
|
|
<transition
|
|
enter-active-class="transition ease-out duration-100"
|
|
enter-from-class="transform opacity-0 scale-95"
|
|
enter-to-class="transform opacity-100 scale-100"
|
|
leave-active-class="transition ease-in duration-75"
|
|
leave-from-class="transform opacity-100 scale-100"
|
|
leave-to-class="transform opacity-0 scale-95"
|
|
>
|
|
<MenuItems
|
|
class="absolute bg-zinc-900 right-0 top-10 z-50 w-96 origin-top-right focus:outline-none shadow-md rounded-md overflow-hidden"
|
|
>
|
|
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
|
<h3 class="text-sm font-semibold text-zinc-100">Notifications</h3>
|
|
<button
|
|
v-if="unreadCount > 0"
|
|
@click="markAllRead()"
|
|
class="text-xs text-blue-500 hover:text-blue-400"
|
|
>
|
|
Mark all read
|
|
</button>
|
|
</div>
|
|
|
|
<div class="max-h-96 overflow-y-auto">
|
|
<div v-if="!loaded" class="px-4 py-6 text-center text-sm text-zinc-500">
|
|
Loading…
|
|
</div>
|
|
<div v-else-if="error" class="px-4 py-4 text-xs text-red-500">
|
|
Couldn't load notifications: {{ error }}
|
|
</div>
|
|
<div
|
|
v-else-if="notifications.length === 0"
|
|
class="px-4 py-6 text-center text-sm text-zinc-500"
|
|
>
|
|
You're all caught up.
|
|
</div>
|
|
<button
|
|
v-else
|
|
v-for="n in notifications"
|
|
:key="n.id"
|
|
@click="() => !n.read && markRead(n.id)"
|
|
class="w-full text-left px-4 py-3 border-b border-zinc-800/60 last:border-0 hover:bg-zinc-800/60 transition"
|
|
:class="!n.read ? 'bg-blue-500/5' : ''"
|
|
>
|
|
<div class="flex items-start gap-x-2">
|
|
<div
|
|
class="mt-1 h-1.5 w-1.5 rounded-full shrink-0"
|
|
:class="n.read ? 'bg-transparent' : 'bg-blue-500'"
|
|
/>
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-zinc-200 truncate">{{ n.title }}</p>
|
|
<p class="text-xs text-zinc-400 mt-0.5 line-clamp-2">{{ n.description }}</p>
|
|
<p class="text-[11px] text-zinc-600 mt-1">{{ formatDate(n.created) }}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</MenuItems>
|
|
</transition>
|
|
</Menu>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Menu, MenuButton, MenuItems } from "@headlessui/vue";
|
|
import { BellIcon } from "@heroicons/vue/20/solid";
|
|
import HeaderWidget from "./HeaderWidget.vue";
|
|
import { useNotifications } from "~/composables/notifications";
|
|
|
|
const { notifications, loaded, error, unreadCount, startPolling, markRead, markAllRead } =
|
|
useNotifications();
|
|
|
|
startPolling();
|
|
|
|
function formatDate(iso: string) {
|
|
try {
|
|
const date = new Date(iso);
|
|
const diffMs = Date.now() - date.getTime();
|
|
const diffMin = Math.round(diffMs / 60000);
|
|
if (diffMin < 1) return "just now";
|
|
if (diffMin < 60) return `${diffMin}m ago`;
|
|
const diffHr = Math.round(diffMin / 60);
|
|
if (diffHr < 24) return `${diffHr}h ago`;
|
|
return date.toLocaleDateString();
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
</script>
|