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).
144 lines
5.4 KiB
Vue
144 lines
5.4 KiB
Vue
<template>
|
|
<Menu as="div" class="relative inline-block">
|
|
<MenuButton>
|
|
<HeaderWidget :notifications="incomingCount > 0 ? incomingCount : undefined">
|
|
<UserGroupIcon 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">Friends</h3>
|
|
<NuxtLink to="/community/friends" class="text-xs text-blue-500 hover:text-blue-400">
|
|
Manage
|
|
</NuxtLink>
|
|
</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 friends: {{ error }}
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div v-if="friends.incomingRequests.length > 0" class="px-4 pt-3 pb-1">
|
|
<p class="text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
|
|
Requests
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-for="req in friends.incomingRequests"
|
|
:key="req.id"
|
|
class="flex items-center gap-x-2 px-4 py-2"
|
|
>
|
|
<CommunityAvatar :url="req.user.avatarUrl" />
|
|
<span class="text-sm text-zinc-200 truncate flex-1">
|
|
{{ req.user.displayName }}
|
|
</span>
|
|
<button
|
|
@click="accept(req.id)"
|
|
class="text-xs text-green-500 hover:text-green-400 font-semibold"
|
|
>
|
|
Accept
|
|
</button>
|
|
<button
|
|
@click="decline(req.id)"
|
|
class="text-xs text-zinc-500 hover:text-zinc-300 font-semibold"
|
|
>
|
|
Decline
|
|
</button>
|
|
</div>
|
|
|
|
<div class="px-4 pt-3 pb-1" v-if="friends.friends.length > 0">
|
|
<p class="text-[11px] uppercase tracking-wide text-zinc-500 font-semibold">
|
|
{{ onlineCount }} online
|
|
</p>
|
|
</div>
|
|
<NuxtLink
|
|
v-for="friend in sortedFriends"
|
|
:key="friend.id"
|
|
:to="`/community/profile/${friend.username}`"
|
|
class="flex items-center gap-x-2 px-4 py-2 hover:bg-zinc-800/60"
|
|
>
|
|
<div class="relative">
|
|
<CommunityAvatar :url="friend.avatarUrl" />
|
|
<span
|
|
class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-zinc-900"
|
|
:class="activity[friend.id]?.online ? 'bg-green-500' : 'bg-zinc-600'"
|
|
/>
|
|
</div>
|
|
<div class="min-w-0">
|
|
<p class="text-sm text-zinc-200 truncate">{{ friend.displayName }}</p>
|
|
<p class="text-xs text-zinc-500 truncate">
|
|
{{
|
|
activity[friend.id]?.playing
|
|
? `Playing ${activity[friend.id]?.playing?.name}`
|
|
: activity[friend.id]?.online
|
|
? "Online"
|
|
: "Offline"
|
|
}}
|
|
</p>
|
|
</div>
|
|
</NuxtLink>
|
|
|
|
<div
|
|
v-if="friends.friends.length === 0 && friends.incomingRequests.length === 0"
|
|
class="px-4 py-6 text-center text-sm text-zinc-500"
|
|
>
|
|
No friends yet.
|
|
<NuxtLink to="/community/friends" class="text-blue-500 hover:text-blue-400 block mt-1">
|
|
Find people to add
|
|
</NuxtLink>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</MenuItems>
|
|
</transition>
|
|
</Menu>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Menu, MenuButton, MenuItems } from "@headlessui/vue";
|
|
import { UserGroupIcon } from "@heroicons/vue/20/solid";
|
|
import HeaderWidget from "./HeaderWidget.vue";
|
|
import { useFriends } from "~/composables/friends";
|
|
|
|
const { friends, activity, loaded, error, onlineCount, incomingCount, refresh, acceptRequest, declineRequest } =
|
|
useFriends();
|
|
|
|
refresh();
|
|
// Light polling for presence -- the friends REST list doesn't ride the
|
|
// community websocket (that's reserved for chat + presence broadcast to
|
|
// avoid every open dropdown independently subscribing/unsubscribing to
|
|
// presence:friends); this keeps "online now"/"playing X" reasonably fresh
|
|
// without wiring a second consumer onto the socket.
|
|
const interval = setInterval(refresh, 30_000);
|
|
onUnmounted(() => clearInterval(interval));
|
|
|
|
const sortedFriends = computed(() =>
|
|
[...friends.value.friends].sort((a, b) => {
|
|
const aOnline = activity.value[a.id]?.online ? 1 : 0;
|
|
const bOnline = activity.value[b.id]?.online ? 1 : 0;
|
|
return bOnline - aOnline;
|
|
}),
|
|
);
|
|
|
|
async function accept(id: string) {
|
|
await acceptRequest(id);
|
|
}
|
|
async function decline(id: string) {
|
|
await declineRequest(id);
|
|
}
|
|
</script>
|