username update, status message update, status overwriting, global

status in client
This commit is contained in:
Soph :3 2026-01-13 00:20:10 +02:00
parent 9ffb3cf283
commit d9f5919b60
11 changed files with 235 additions and 79 deletions

View file

@ -14,7 +14,8 @@
import Button, { buttonVariants } from './ui/button/button.svelte'; import Button, { buttonVariants } from './ui/button/button.svelte';
import User from './extra/User.svelte'; import User from './extra/User.svelte';
import type { SessionValidationResult } from '$lib/server/auth'; import type { SessionValidationResult } from '$lib/server/auth';
import type { OverviewData } from '$lib'; import { Status, statuses, type OverviewData, type OverviewUser } from '$lib';
import Label from './ui/label/label.svelte';
let { let {
currentPage = $bindable<string | null>(), currentPage = $bindable<string | null>(),
@ -259,6 +260,7 @@
subPage = null; subPage = null;
}} }}
user={friend} user={friend}
crown={false}
></User> ></User>
</Sidebar.MenuSubButton> </Sidebar.MenuSubButton>
</Sidebar.MenuSubItem> </Sidebar.MenuSubItem>
@ -349,5 +351,66 @@
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.Group> </Sidebar.Group>
</Sidebar.Content> </Sidebar.Content>
<Sidebar.Footer class="border-t-2 p-2">
<Dialog.Root>
<Dialog.Trigger>
<User user={user as unknown as OverviewUser} crown={false} />
</Dialog.Trigger>
<Dialog.Content>
<form method="POST" action="?/updateProfile">
<Dialog.Header>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>Update how others see you.</Dialog.Description>
</Dialog.Header>
<div class="">
<Label for="userName">Username</Label>
<Input
id="userName"
name="userName"
placeholder="Your name"
value={user?.username}
required
minlength={2}
maxlength={32}
/>
</div>
<!-- Presence -->
<div class="space-y-1">
<Label for="status">Status</Label>
<select id="status" name="status" class="input" required>
<option value={Status.ONLINE} selected={user?.statusOverwrite === Status.ONLINE}
>Online</option
>
<option value={Status.DND} selected={user?.statusOverwrite === Status.DND}
>Do Not Disturb</option
>
<option value={Status.OFFLINE} selected={user?.statusOverwrite === Status.OFFLINE}
>Offline</option
>
</select>
</div>
<div class="space-y-1">
<Label for="statusMessage">Status message</Label>
<Input
id="statusMessage"
name="statusMessage"
placeholder="What's going on?"
value={user?.statusMessage ?? ''}
maxlength={64}
/>
</div>
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Dialog.Close>
<Button type="submit">Save</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</Sidebar.Footer>
<Sidebar.Rail /> <Sidebar.Rail />
</Sidebar.Root> </Sidebar.Root>

View file

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Status, type UserWithStatus } from '$lib'; import { Status, statuses, type OverviewUser } from '$lib';
import Crown from '@lucide/svelte/icons/crown'; import Crown from '@lucide/svelte/icons/crown';
const { const {
onclick, onclick,
user, user,
crown crown
}: { crown: boolean; onclick?: (e: MouseEvent) => void; user: UserWithStatus } = $props(); }: { crown: boolean; onclick?: (e: MouseEvent) => void; user: OverviewUser } = $props();
let status: Status | undefined = $derived(statuses.get(user.id));
</script> </script>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
@ -16,20 +18,23 @@
alt={user.username} alt={user.username}
class="size-6 rounded-full" class="size-6 rounded-full"
/> />
{#if status}
<div class="relative"> <div class="relative">
{#if user.status === Status.OFFLINE} {#if status.status === Status.OFFLINE}
<span <span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-gray-500 ring-1 ring-white" class="absolute end-0 bottom-0 block size-2 rounded-full bg-gray-500 ring-1 ring-white"
></span> ></span>
{:else if user.status === Status.DND} {:else if status.status === Status.DND}
<span class="absolute end-0 bottom-0 block size-2 rounded-full bg-red-500 ring-1 ring-white" <span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-red-500 ring-1 ring-white"
></span> ></span>
{:else if user.status === Status.ONLINE} {:else if status.status === Status.ONLINE}
<span <span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-green-500 ring-1 ring-white" class="absolute end-0 bottom-0 block size-2 rounded-full bg-green-500 ring-1 ring-white"
></span> ></span>
{/if} {/if}
</div> </div>
{/if}
</div> </div>
<div> <div>
<a <a
@ -46,8 +51,10 @@
<Crown></Crown> <Crown></Crown>
{/if} {/if}
</a> </a>
{#if status}
<div class="pl-2 text-xs text-gray-400 italic"> <div class="pl-2 text-xs text-gray-400 italic">
{user.statusMessage} {status.statusMessage}
</div> </div>
{/if}
</div> </div>
</div> </div>

View file

@ -12,7 +12,7 @@
type OverviewData, type OverviewData,
type OverviewGroup, type OverviewGroup,
type OverviewServer, type OverviewServer,
type UserWithStatus type OverviewUser
} from '$lib'; } from '$lib';
import Button from './ui/button/button.svelte'; import Button from './ui/button/button.svelte';
import Input from './ui/input/input.svelte'; import Input from './ui/input/input.svelte';
@ -20,14 +20,14 @@
// Props for the member sidebar. // Props for the member sidebar.
let { let {
open = $bindable(true), open = $bindable(true),
members = $bindable<UserWithStatus[]>([]), members = $bindable<OverviewUser[]>([]),
user, user,
data, data,
currentEntity, currentEntity,
currentEntityId = $bindable<string | null>(null) currentEntityId = $bindable<string | null>(null)
}: { }: {
open: boolean; open: boolean;
members: UserWithStatus[]; members: OverviewUser[];
data: OverviewData; data: OverviewData;
user: SessionValidationResult['user']; user: SessionValidationResult['user'];
currentEntity: OverviewGroup | OverviewServer; currentEntity: OverviewGroup | OverviewServer;
@ -49,13 +49,13 @@
{#if user && currentEntityId} {#if user && currentEntityId}
<Dialog.Root> <Dialog.Root>
<Dialog.Trigger><Button variant="outline"><Cog></Cog></Button></Dialog.Trigger> <Dialog.Trigger><Button variant="outline"><Cog></Cog></Button></Dialog.Trigger>
<Dialog.Content class="sm:max-w-[425px]"> <Dialog.Content class="sm:max-w-106.25">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Group Settings</Dialog.Title> <Dialog.Title>Group Settings</Dialog.Title>
<Dialog.Description>Configure your group settings here.</Dialog.Description> <Dialog.Description>Configure your group settings here.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<Tabs.Root value="users" class="w-[400px]"> <Tabs.Root value="users" class="w-100">
<Tabs.List class="grid w-full grid-cols-2"> <Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="users">User Permissions</Tabs.Trigger> <Tabs.Trigger value="users">User Permissions</Tabs.Trigger>
{#if user.id == currentEntity.ownerId} {#if user.id == currentEntity.ownerId}
@ -194,9 +194,7 @@
{#each members as member (member.id)} {#each members as member (member.id)}
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton> <Sidebar.MenuButton>
{#snippet child({ props })}
<User user={member} crown={member.id == currentEntity.ownerId} /> <User user={member} crown={member.id == currentEntity.ownerId} />
{/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
{/each} {/each}

View file

@ -1,4 +1,5 @@
import { definePrefix, type Puuid } from './puuid'; import { definePrefix, type Puuid } from './puuid';
import { SvelteMap } from 'svelte/reactivity';
export const UserID = definePrefix('user'); export const UserID = definePrefix('user');
export const GroupID = definePrefix('group'); export const GroupID = definePrefix('group');
@ -14,6 +15,12 @@ export type ServerId = Puuid<'srv'>;
export type DirectMessageId = Puuid<'dmid'>; export type DirectMessageId = Puuid<'dmid'>;
export type ChannelId = Puuid<'ch'>; export type ChannelId = Puuid<'ch'>;
export interface Status {
statusMessage: string;
status: 1 | 2 | 3;
}
export const statuses: Map<string, Status> = new SvelteMap();
export const Status: Record<string, 1 | 2 | 3> = { export const Status: Record<string, 1 | 2 | 3> = {
OFFLINE: 1, OFFLINE: 1,
DND: 2, DND: 2,
@ -65,13 +72,8 @@ export type OverviewGroup = {
}; };
}; };
export interface UserWithStatus extends OverviewUser {
status: 1 | 2 | 3;
statusMessage: string;
}
export interface OverviewData { export interface OverviewData {
friends: UserWithStatus[]; friends: OverviewUser[];
groups: OverviewGroup[]; groups: OverviewGroup[];
servers: OverviewServer[]; servers: OverviewServer[];
} }

View file

@ -2,7 +2,7 @@ import type { RequestEvent } from '@sveltejs/kit';
import { eq, inArray, or } from 'drizzle-orm'; import { eq, inArray, or } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2'; import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db'; import { db, kvStore } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import { _findDmId } from '../../routes/api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server'; import { _findDmId } from '../../routes/api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server';
@ -159,7 +159,8 @@ export async function validateSessionToken(token: string) {
} }
}; };
}), }),
friendRequests friendRequests,
statusMessage: kvStore.get('user-' + user.id + '-message')
} }
}; };
} }

View file

@ -6,3 +6,8 @@ const sqlite = new Database('database.db');
export const db = drizzle(sqlite); export const db = drizzle(sqlite);
export const kvStore = new BunSqliteKeyValue('./kvStore.db'); export const kvStore = new BunSqliteKeyValue('./kvStore.db');
console.log('nuking kvstore');
kvStore.getItems()?.forEach((z) => {
if (z.key.endsWith('state')) kvStore.set(z.key, 1);
});

View file

@ -1,7 +1,7 @@
import { db, kvStore } from '$lib/server/db'; import { db, kvStore } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
import { GroupID, ServerID } from '$lib'; import { GroupID, ServerID, Status } from '$lib';
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@ -27,8 +27,15 @@ export const GET: RequestHandler = async ({ params }) => {
return json({ return json({
members: members.map((member) => ({ members: members.map((member) => ({
id: member.id, id: member.id,
status: kvStore.get('user-' + member.id + '-state'), status:
statusMessage: Math.random() > 0.5 ? 'vibing 🟢' : 'not vibing', member.statusOverwrite == Status.OFFLINE
? Status.OFFLINE
: kvStore.get('user-' + member.id + '-state'),
statusMessage:
kvStore.get('user-' + member.id + '-state') != Status.OFFLINE &&
member.statusOverwrite != Status.OFFLINE
? kvStore.get('user-' + member.id + '-message')
: '',
username: member.username, username: member.username,
image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}` image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}`
})) }))
@ -50,8 +57,15 @@ export const GET: RequestHandler = async ({ params }) => {
return json({ return json({
members: members.map((member) => ({ members: members.map((member) => ({
id: member.id, id: member.id,
status: kvStore.get('user-' + member.id + '-state'), status:
statusMessage: Math.random() > 0.5 ? 'vibing 🟢' : 'not vibing', member.statusOverwrite == Status.OFFLINE
? Status.OFFLINE
: kvStore.get('user-' + member.id + '-state'),
statusMessage:
kvStore.get('user-' + member.id + '-state') != Status.OFFLINE &&
member.statusOverwrite != Status.OFFLINE
? kvStore.get('user-' + member.id + '-message')
: '',
username: member.username, username: member.username,
image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}` image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}`
})) }))

View file

@ -1,19 +1,32 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { kvStore } from '$lib/server/db'; import { db, kvStore } from '$lib/server/db';
import { Status } from '$lib'; import { Status } from '$lib';
import * as table from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const { userId } = params; const { userId } = params;
const user = await db
.select({
statusOverwrite: table.user.statusOverwrite
})
.from(table.user)
.where(eq(table.user.id, userId));
if (!user || user.length == 0) {
return new Response('User missing', { status: 404 });
}
let current = kvStore.get('user-' + userId + '-state');
if (user[0].statusOverwrite == Status.OFFLINE) {
current = Status.OFFLINE;
}
return json({ return json({
userId, userId,
status: kvStore.get('user-' + userId + '-state'), status: current,
statusMessage: statusMessage: current != Status.OFFLINE ? kvStore.get('user-' + userId + '-message') : ''
kvStore.get('user-' + userId + '-state') != Status.OFFLINE
? Math.random() > 0.5
? 'vibing 🟢'
: 'not vibing'
: ''
}); });
}; };

View file

@ -22,7 +22,7 @@ export function _sendToSubscribers(id: string, payload: unknown) {
} }
} }
export function _sendToUser(userId: string, payload: unknown) { export function _sendToUser(userId: string, payload: unknown) {
for (const [_, client] of _clients) { for (const [, client] of _clients) {
if (client.userId == userId) { if (client.userId == userId) {
client.controller.enqueue(`data: ${JSON.stringify(payload)}\n\n`); client.controller.enqueue(`data: ${JSON.stringify(payload)}\n\n`);
} }
@ -59,10 +59,15 @@ export async function GET({ locals, request }) {
if (overwrite === Status.DND) { if (overwrite === Status.DND) {
kvStore.set(`user-${userId}-state`, Status.DND); kvStore.set(`user-${userId}-state`, Status.DND);
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.DND }); _sendToSubscribers(userId, {
} else { type: 'status',
kvStore.set(`user-${userId}-state`, Status.ONLINE); id: userId,
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.ONLINE }); status: Status.DND,
statusMessage:
kvStore.get('user-' + userId + '-state') != Status.OFFLINE
? kvStore.get('user-' + userId + '-message')
: ''
});
} }
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
@ -74,7 +79,12 @@ export async function GET({ locals, request }) {
if (overwrite === Status.OFFLINE) return; if (overwrite === Status.OFFLINE) return;
kvStore.set(`user-${userId}-state`, Status.OFFLINE); kvStore.set(`user-${userId}-state`, Status.OFFLINE);
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.OFFLINE }); _sendToSubscribers(userId, {
type: 'status',
id: userId,
status: Status.OFFLINE,
statusMessage: ''
});
}); });
}, },
cancel() { cancel() {
@ -85,7 +95,12 @@ export async function GET({ locals, request }) {
if (overwrite === Status.OFFLINE) return; if (overwrite === Status.OFFLINE) return;
kvStore.set(`user-${userId}-state`, Status.OFFLINE); kvStore.set(`user-${userId}-state`, Status.OFFLINE);
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.OFFLINE }); _sendToSubscribers(userId, {
type: 'status',
id: userId,
status: Status.OFFLINE,
statusMessage: ''
});
} }
}); });

View file

@ -1,14 +1,15 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { getRequestEvent } from '$app/server'; import { getRequestEvent } from '$app/server';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db'; import { db, kvStore } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import { DirectMessageID, FriendRequestID, GroupID, ServerID } from '$lib'; import { DirectMessageID, FriendRequestID, GroupID, ServerID } from '$lib';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { and } from 'drizzle-orm'; import { and } from 'drizzle-orm';
import { type User } from '$lib/server/db/schema'; import { type User } from '$lib/server/db/schema';
import { _sendToSubscribers, _sendToUser } from '../api/updates/+server'; import { _sendToSubscribers, _sendToUser } from '../api/updates/+server';
import { _findDmId } from '../api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server'; import { validateUsername } from '$lib/server/auth';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const user = requireLogin(); const user = requireLogin();
return { user }; return { user };
@ -196,6 +197,41 @@ export const actions = {
return { success: true }; return { success: true };
}, },
updateProfile: async ({ request, locals }) => {
const user = locals.user;
if (!user) return fail(401);
const data = await request.formData();
const userName = data.get('userName')?.toString().trim();
const status = data.has('status') ? +data.get('status')! : undefined;
const statusMessage = data.get('statusMessage')?.toString().trim() || undefined;
if (!validateUsername(userName)) {
return fail(400, { message: 'Invalid display name' });
}
if (status != 1 && status != 2 && status != 3) {
return fail(400, { message: 'Invalid status' });
}
kvStore.set('user-' + user.id + '-message', statusMessage);
await db
.update(table.user)
.set({
username: userName,
statusOverwrite: status
})
.where(eq(table.user.id, user.id));
if (statusMessage || status != user.statusOverwrite)
_sendToSubscribers(user.id, { type: 'status', id: user.id, status: status, statusMessage });
if (userName != user.username)
_sendToSubscribers(user.id, { type: 'username', status: 'name-changed' });
return { success: true };
},
createGroup: async ({ request, locals }) => { createGroup: async ({ request, locals }) => {
const data = await request.formData(); const data = await request.formData();
const members = data.getAll('member').map((z) => z.toString()); const members = data.getAll('member').map((z) => z.toString());

View file

@ -11,11 +11,11 @@
type OverviewUser, type OverviewUser,
type OverviewGroup, type OverviewGroup,
type OverviewServer, type OverviewServer,
type UserWithStatus,
type ReturnMessage, type ReturnMessage,
type Channel, type Channel,
type ChannelId, type ChannelId,
ChannelID ChannelID,
statuses
} from '$lib'; } from '$lib';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
import AppSidebar from '$lib/components/app-sidebar.svelte'; import AppSidebar from '$lib/components/app-sidebar.svelte';
@ -43,7 +43,7 @@
let sse: EventSource | undefined; let sse: EventSource | undefined;
let messagesElement: HTMLDivElement | undefined = $state(); let messagesElement: HTMLDivElement | undefined = $state();
let isMembersTabOpen = $state(true); let isMembersTabOpen = $state(true);
let members: UserWithStatus[] = $state([]); let members: OverviewUser[] = $state([]);
let messages: ReturnMessage[] = $state([]); let messages: ReturnMessage[] = $state([]);
let inputValue = $state(); let inputValue = $state();
@ -88,11 +88,14 @@
const status = await res.json(); const status = await res.json();
statuses.set(friend.id, {
status: status.status,
statusMessage: status.statusMessage
});
return { return {
id: UserID.parse(friend.id), id: UserID.parse(friend.id),
username: friend.username, username: friend.username,
status: status.status,
statusMessage: status.statusMessage,
dmId: friend.dmId, dmId: friend.dmId,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + friend.username image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + friend.username
}; };
@ -198,9 +201,10 @@
const json = JSON.parse(e.data) as const json = JSON.parse(e.data) as
| { type: 'connected'; sessionId: string } | { type: 'connected'; sessionId: string }
| { type: 'message'; message: ReturnMessage } | { type: 'message'; message: ReturnMessage }
| { type: 'status'; id: string; status: 1 | 2 | 3 } | { type: 'status'; id: string; status: 1 | 2 | 3; statusMessage: string }
| { type: 'friends'; status: string } | { type: 'friends'; status: string }
| { type: 'group'; status: string } | { type: 'group'; status: string }
| { type: 'username'; status: string }
| { type: 'server'; status: string }; | { type: 'server'; status: string };
if (json.type == 'friends') { if (json.type == 'friends') {
toast('Invalidation from friends updates, recieved ' + json.status); toast('Invalidation from friends updates, recieved ' + json.status);
@ -216,25 +220,29 @@
toast('Invalidation from group updates, recieved ' + json.status); toast('Invalidation from group updates, recieved ' + json.status);
if (json.status == 'removed-from-group' && GroupID.is(currentPageID)) { if (json.status == 'removed-from-group' && GroupID.is(currentPageID)) {
currentPageID = null; currentPageID = null;
currentPage = null; currentPage = undefined;
} }
await invalidateAll(); await invalidateAll();
await fill_overview_data(); await fill_overview_data();
} }
if (json.type == 'username') {
toast('Invalidation from username updates, recieved ' + json.status);
await invalidateAll();
await fill_overview_data();
}
if (json.type == 'connected') { if (json.type == 'connected') {
console.log('SSE connected. We are sessionID ' + json.sessionId); console.log('SSE connected. We are sessionID ' + json.sessionId);
sessionId = json.sessionId; sessionId = json.sessionId;
} }
if (json.type == 'status') { if (json.type == 'status') {
//@TODO update everywhere where user is used statuses.set(json.id, {
const friend = overview_data.friends.find((z) => z.id == json.id); status: json.status,
statusMessage: json.statusMessage
if (friend) { });
friend.status = json.status;
}
} }
if (json.type == 'message') { if (json.type == 'message') {
@ -294,18 +302,12 @@
<h1>{server!.name} - Server Info</h1> <h1>{server!.name} - Server Info</h1>
{/if} {/if}
{:else if UserID.is(currentPageID)} {:else if UserID.is(currentPageID)}
{@const friend = currentPage as UserWithStatus} {@const friend = currentPage as OverviewUser}
<img src={friend.image} alt={friend!.username} class="size-6 rounded-full" /> <img src={friend.image} alt={friend!.username} class="size-6 rounded-full" />
<h1> <h1>
{friend!.username} [{friend.status == Status.ONLINE {friend!.username}
? 'Online!'
: friend.status == Status.DND
? 'DND'
: friend.status == Status.OFFLINE
? 'Offline'
: 'Unknown'}]
</h1> </h1>
{:else if GroupID.is(currentPageID)} {:else if GroupID.is(currentPageID)}
{@const group = currentPage as OverviewGroup} {@const group = currentPage as OverviewGroup}