add members from group, remove members from group, change title of group

This commit is contained in:
Soph :3 2026-01-11 14:57:39 +02:00
parent 7af96ca084
commit 17778e1736
9 changed files with 266 additions and 22 deletions

View file

@ -37,6 +37,7 @@
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.45.6", "svelte": "^5.45.6",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
@ -683,6 +684,8 @@
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="], "svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
@ -769,6 +772,8 @@
"mode-watcher/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="], "mode-watcher/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],

View file

@ -43,6 +43,7 @@
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.45.6", "svelte": "^5.45.6",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",

View file

@ -9,22 +9,26 @@
import { import {
GroupID, GroupID,
ServerID, ServerID,
type OverviewData,
type OverviewGroup, type OverviewGroup,
type OverviewServer, type OverviewServer,
type UserWithStatus type UserWithStatus
} 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';
// Props for the member sidebar. // Props for the member sidebar.
let { let {
open = $bindable(true), open = $bindable(true),
members = $bindable<UserWithStatus[]>([]), members = $bindable<UserWithStatus[]>([]),
user, user,
data,
currentEntity, currentEntity,
currentEntityId = $bindable<string | null>(null) currentEntityId = $bindable<string | null>(null)
}: { }: {
open: boolean; open: boolean;
members: UserWithStatus[]; members: UserWithStatus[];
data: OverviewData;
user: SessionValidationResult['user']; user: SessionValidationResult['user'];
currentEntity: OverviewGroup | OverviewServer; currentEntity: OverviewGroup | OverviewServer;
currentEntityId: string | null; currentEntityId: string | null;
@ -104,15 +108,59 @@
</Tabs.Content> </Tabs.Content>
{/if} {/if}
<Tabs.Content value="users"> <Tabs.Content value="users">
<div class="space-y-4 p-2"> <div class="space-y-6 p-2">
{#if (currentEntity as OverviewGroup).permissions.addMembers || user.id == currentEntity.ownerId} {#if (currentEntity as OverviewGroup).permissions.addMembers || user.id == currentEntity.ownerId}
<h1>you have permission to add members</h1> {@const addableMembers = data.friends.filter(
(z) => !members.find((h) => h.id == z.id)
)}
{#if addableMembers.length !== 0}
<form method="POST" action="?/addMembers" class="space-y-4">
<input type="hidden" name="groupId" value={currentEntityId} />
<h1>Add members</h1>
{#each addableMembers as friend (friend.id)}
<label class="flex items-center gap-2">
<input type="checkbox" name="memberIds" value={friend.id} />
<User crown={false} user={friend} />
</label>
{/each}
<Button type="submit">Add selected members</Button>
</form>
<hr />
{/if}
{/if} {/if}
{#if (currentEntity as OverviewGroup).permissions.changeTitle || user.id == currentEntity.ownerId} {#if (currentEntity as OverviewGroup).permissions.changeTitle || user.id == currentEntity.ownerId}
<h1>you have permission to change title</h1> <form method="POST" action="?/changeTitle" class="space-y-4">
<input type="hidden" name="groupId" value={currentEntityId} />
<h1>Change title</h1>
<Input name="title" placeholder="New group title" required />
<Button type="submit">Change title</Button>
</form>
<hr />
{/if} {/if}
{#if (currentEntity as OverviewGroup).permissions.removeMembers || user.id == currentEntity.ownerId} {#if (currentEntity as OverviewGroup).permissions.removeMembers || user.id == currentEntity.ownerId}
<h1>you have permission to remove members</h1> <form method="POST" action="?/removeMembers" class="space-y-4">
<input type="hidden" name="groupId" value={currentEntityId} />
<h1>Remove members</h1>
{#each members.filter((z) => z.id != currentEntity.ownerId) as member (member.id)}
<label class="flex items-center gap-2">
<input type="checkbox" name="memberIds" value={member.id} />
<User crown={false} user={member} />
</label>
{/each}
<Button type="submit">Remove selected members</Button>
</form>
{/if} {/if}
</div> </div>
</Tabs.Content> </Tabs.Content>

View file

@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View file

@ -0,0 +1,34 @@
<script lang="ts">
import CircleCheckIcon from "@lucide/svelte/icons/circle-check";
import InfoIcon from "@lucide/svelte/icons/info";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import OctagonXIcon from "@lucide/svelte/icons/octagon-x";
import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert";
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
>{#snippet loadingIcon()}
<Loader2Icon class="size-4 animate-spin" />
{/snippet}
{#snippet successIcon()}
<CircleCheckIcon class="size-4" />
{/snippet}
{#snippet errorIcon()}
<OctagonXIcon class="size-4" />
{/snippet}
{#snippet infoIcon()}
<InfoIcon class="size-4" />
{/snippet}
{#snippet warningIcon()}
<TriangleAlertIcon class="size-4" />
{/snippet}
</Sonner>

View file

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import { Toaster } from '$lib/components/ui/sonner/index.js';
let { children } = $props(); let { children } = $props();
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
<Toaster />
<ModeWatcher /> <ModeWatcher />
{@render children()} {@render children()}

View file

@ -21,6 +21,13 @@ export function _sendToSubscribers(id: string, payload: unknown) {
} }
} }
} }
export function _sendToUser(userId: string, payload: unknown) {
for (const [_, client] of _clients) {
if (client.userId == userId) {
client.controller.enqueue(`data: ${JSON.stringify(payload)}\n\n`);
}
}
}
export function _isUserConnected(userId: string): boolean { export function _isUserConnected(userId: string): boolean {
for (const client of _clients.values()) { for (const client of _clients.values()) {

View file

@ -7,7 +7,7 @@ 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 } from '../api/updates/+server'; import { _sendToSubscribers, _sendToUser } from '../api/updates/+server';
import { _findDmId } from '../api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server'; import { _findDmId } from '../api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const user = requireLogin(); const user = requireLogin();
@ -245,6 +245,7 @@ export const actions = {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
for await (const member of members) { for await (const member of members) {
_sendToUser(member, { type: 'group', status: 'added-to-group' });
const user = await tx.select().from(table.user).where(eq(table.user.id, member)).limit(1); const user = await tx.select().from(table.user).where(eq(table.user.id, member)).limit(1);
await tx await tx
.update(table.user) .update(table.user)
@ -293,6 +294,138 @@ export const actions = {
return { success: true }; return { success: true };
}, },
addMembers: async ({ request, locals }) => {
const data = await request.formData();
const groupId = data.get('groupId');
const memberIds = data.getAll('memberIds').map(String);
if (typeof groupId !== 'string') {
return fail(400, { error: 'Invalid group ID' });
}
if (!memberIds.length) {
return fail(400, { error: 'No members selected' });
}
const group = await db.select().from(table.group).where(eq(table.group.id, groupId)).limit(1);
if (!group.length) return fail(404, { error: 'Group not found' });
const g = group[0];
if (!(g.members as string[]).includes(locals.user!.id)) {
return fail(403, { error: 'You do not have permission to act on this group.' });
}
const isOwner = g.owner === locals.user!.id;
if (!isOwner && !g.addMembers) {
return fail(403, { error: 'No permission to add members' });
}
for (const id of memberIds) {
if (!locals.user!.friends.find((f) => f.id === id)) {
return fail(403, { error: 'Can only add friends' });
}
}
const newMembers = [...new Set([...(g.members as string[]), ...memberIds])];
await db.transaction(async (tx) => {
await tx.update(table.group).set({ members: newMembers }).where(eq(table.group.id, groupId));
for (const id of memberIds) {
const user = await tx.select().from(table.user).where(eq(table.user.id, id)).limit(1);
if (!user.length) continue;
if (newMembers.includes(id)) {
_sendToUser(id, { type: 'group', status: 'added-to-group' });
} else {
_sendToUser(id, { type: 'group', status: 'member-added-to-group' });
}
await tx
.update(table.user)
.set({ groups: (user[0].groups as string[]).concat(groupId) })
.where(eq(table.user.id, id));
}
});
return { success: true };
},
removeMembers: async ({ request, locals }) => {
const data = await request.formData();
const groupId = data.get('groupId');
const memberIds = data.getAll('memberIds').map(String);
if (typeof groupId !== 'string') {
return fail(400, { error: 'Invalid group ID' });
}
const group = await db.select().from(table.group).where(eq(table.group.id, groupId)).limit(1);
if (!group.length) return fail(404, { error: 'Group not found' });
const g = group[0];
if (!(g.members as string[]).includes(locals.user!.id)) {
return fail(403, { error: 'You do not have permission to act on this group.' });
}
const isOwner = g.owner === locals.user!.id;
if (!isOwner && !g.removeMembers) {
return fail(403, { error: 'No permission to remove members' });
}
if (memberIds.includes(g.owner)) {
return fail(400, { error: 'Cannot remove group owner' });
}
const remaining = (g.members as string[]).filter((id) => !memberIds.includes(id));
await db.transaction(async (tx) => {
await tx.update(table.group).set({ members: remaining }).where(eq(table.group.id, groupId));
for (const id of remaining) {
_sendToUser(id, { type: 'group', status: 'someone-was-removed' });
}
for (const id of memberIds) {
_sendToUser(id, { type: 'group', status: 'removed-from-group' });
await tx
.update(table.user)
.set({
groups: locals.user!.groups.map((z) => z.id).filter((g) => g !== groupId)
})
.where(eq(table.user.id, id));
}
});
return { success: true };
},
changeTitle: async ({ request, locals }) => {
const data = await request.formData();
const groupId = data.get('groupId');
const title = data.get('title');
if (typeof groupId !== 'string' || typeof title !== 'string' || title.length < 1) {
return fail(400, { error: 'Invalid input' });
}
const group = await db.select().from(table.group).where(eq(table.group.id, groupId)).limit(1);
if (!group.length) return fail(404, { error: 'Group not found' });
const g = group[0];
if (!(g.members as string[]).includes(locals.user!.id)) {
return fail(403, { error: 'You do not have permission to act on this group.' });
}
const isOwner = g.owner === locals.user!.id;
if (!isOwner && !g.changeTitle) {
return fail(403, { error: 'No permission to change title' });
}
await db.update(table.group).set({ name: title }).where(eq(table.group.id, groupId));
//@TODO if a user isn't in the group screen this doesnt get propogated
_sendToSubscribers(groupId, { type: 'group', status: 'name-changed' });
return { success: true };
},
createServer: async ({ request, locals }) => { createServer: async ({ request, locals }) => {
const data = await request.formData(); const data = await request.formData();

View file

@ -30,9 +30,8 @@
import SendHorizontal from '@lucide/svelte/icons/send-horizontal'; import SendHorizontal from '@lucide/svelte/icons/send-horizontal';
import PersonStanding from '@lucide/svelte/icons/person-standing'; import PersonStanding from '@lucide/svelte/icons/person-standing';
import MemberSidebar from '$lib/components/member-sidebar.svelte'; import MemberSidebar from '$lib/components/member-sidebar.svelte';
import User from '$lib/components/extra/User.svelte'; import { invalidateAll } from '$app/navigation';
import { invalidate, invalidateAll } from '$app/navigation'; import { toast } from 'svelte-sonner';
let errorOpen = $state(true); let errorOpen = $state(true);
let { form, data }: { form: ActionData; data: PageServerData } = $props(); let { form, data }: { form: ActionData; data: PageServerData } = $props();
@ -196,13 +195,25 @@
| { 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 }
| { type: 'friends'; status: string }; | { type: 'friends'; status: string }
| { type: 'group'; status: string };
if (json.type == 'friends') { if (json.type == 'friends') {
toast('Invalidation from friends updates, recieved ' + json.status);
await invalidateAll(); await invalidateAll();
await fill_overview_data(); await fill_overview_data();
} }
if (json.type == 'group') {
toast('Invalidation from group updates, recieved ' + json.status);
if (json.status == 'removed-from-group' && GroupID.is(currentPageID)) {
currentPageID = null;
currentPage = null;
}
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;
@ -386,13 +397,16 @@
</Sidebar.Inset> </Sidebar.Inset>
<Sidebar.Provider class="w-0"> <Sidebar.Provider class="w-0">
<Sidebar.Provider class="w-0"> <Sidebar.Provider class="w-0">
<MemberSidebar {#if currentPage && currentPageID && overview_data && members && !UserID.is(currentPageID)}
bind:open={isMembersTabOpen} <MemberSidebar
user={data.user} bind:open={isMembersTabOpen}
{members} user={data.user}
currentEntity={currentPage} data={overview_data}
currentEntityId={currentPageID} {members}
/> currentEntity={currentPage}
currentEntityId={currentPageID}
/>
{/if}
</Sidebar.Provider> </Sidebar.Provider>
</Sidebar.Provider> </Sidebar.Provider>
</Sidebar.Provider> </Sidebar.Provider>