deleting of servers, invites, make a different file for dashboard, make
invites a new citizen, partly fix PSD type, switch to glass and 9x dicebear
This commit is contained in:
parent
578edf32d4
commit
f1dcd0cfc5
10 changed files with 529 additions and 300 deletions
|
|
@ -14,13 +14,12 @@
|
|||
import Button, { buttonVariants } from './ui/button/button.svelte';
|
||||
import User from './extra/User.svelte';
|
||||
import type { SessionValidationResult } from '$lib/server/auth';
|
||||
import { Status, type OverviewUser } from '$lib';
|
||||
import { Status, type AppPSD, type OverviewUser } from '$lib';
|
||||
import Label from './ui/label/label.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { fill_overview_data } from '$lib/state.svelte';
|
||||
import type { PageServerLoad } from '../../routes/app/$types';
|
||||
|
||||
import { overview_data } from '$lib/state.svelte';
|
||||
|
||||
|
|
@ -33,7 +32,7 @@
|
|||
}: {
|
||||
currentPage: string | null;
|
||||
subPage: string | null;
|
||||
psd: PageServerLoad;
|
||||
psd: AppPSD;
|
||||
user: SessionValidationResult['user'];
|
||||
} = $props();
|
||||
</script>
|
||||
|
|
@ -219,10 +218,10 @@
|
|||
{/if}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="manage">
|
||||
{#if data.friends.length === 0}
|
||||
{#if overview_data.friends.length === 0}
|
||||
<p class="text-sm text-muted-foreground">You have no friends added.</p>
|
||||
{:else}
|
||||
{#each data.friends as friend (friend.id)}
|
||||
{#each overview_data.friends as friend (friend.id)}
|
||||
<Card.Root class="mb-2">
|
||||
<Card.Header>
|
||||
<Card.Title>{friend.username}</Card.Title>
|
||||
|
|
@ -492,7 +491,7 @@
|
|||
}}
|
||||
>
|
||||
<img
|
||||
src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + server.name}
|
||||
src={'https://api.dicebear.com/9.x/glass/svg?seed=' + server.name}
|
||||
alt={server.name}
|
||||
class="size-6 rounded-full"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<div class="flex flex-row gap-2">
|
||||
<div>
|
||||
<img
|
||||
src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + user.username}
|
||||
src={'https://api.dicebear.com/9.x/glass/svg?seed=' + user.username}
|
||||
alt={user.username}
|
||||
class="size-6 rounded-full"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import type { SessionValidationResult } from '$lib/server/auth';
|
||||
import {
|
||||
GroupID,
|
||||
type OverviewData,
|
||||
type AppPSD,
|
||||
type OverviewGroup,
|
||||
type OverviewServer,
|
||||
type OverviewUser
|
||||
|
|
@ -19,7 +19,6 @@
|
|||
import { invalidateAll } from '$app/navigation';
|
||||
import { fill_overview_data } from '$lib/state.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { PageServerLoad } from '../../routes/app/$types';
|
||||
|
||||
import { overview_data } from '$lib/state.svelte';
|
||||
|
||||
|
|
@ -34,7 +33,7 @@
|
|||
}: {
|
||||
open: boolean;
|
||||
members: OverviewUser[];
|
||||
psd: PageServerLoad;
|
||||
psd: AppPSD;
|
||||
user: SessionValidationResult['user'];
|
||||
currentEntity: OverviewGroup | OverviewServer;
|
||||
currentEntityId: string | null;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,48 @@ export interface Status {
|
|||
statusMessage: string;
|
||||
status: 1 | 2 | 3;
|
||||
}
|
||||
export type AppPSD = {
|
||||
user: {
|
||||
servers: {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
channels: unknown;
|
||||
invites: unknown;
|
||||
}[];
|
||||
friends: {
|
||||
dmId: string | undefined;
|
||||
id: string;
|
||||
username: string;
|
||||
}[];
|
||||
groups: {
|
||||
members: number;
|
||||
permissions: {
|
||||
changeTitle: boolean;
|
||||
addMembers: boolean;
|
||||
removeMembers: boolean;
|
||||
};
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
changeTitle: number;
|
||||
addMembers: number;
|
||||
removeMembers: number;
|
||||
}[];
|
||||
friendRequests: {
|
||||
id: string;
|
||||
fromUser: string;
|
||||
fromUsername: string;
|
||||
toUsername: string;
|
||||
toUser: string;
|
||||
}[];
|
||||
statusMessage: string;
|
||||
id: string;
|
||||
username: string;
|
||||
statusOverwrite: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const statuses: Map<string, Status> = new SvelteMap();
|
||||
|
||||
export const Status: Record<string, 1 | 2 | 3> = {
|
||||
|
|
@ -40,6 +82,19 @@ export interface Channel {
|
|||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
code: '🎈🏏🎝😺🙕';
|
||||
createdAt: Date;
|
||||
createdBy: {
|
||||
username: string;
|
||||
id: string;
|
||||
};
|
||||
id: string;
|
||||
maxUses: number | null;
|
||||
|
||||
uses: number;
|
||||
}
|
||||
export interface Message {
|
||||
id: string;
|
||||
authorId: string;
|
||||
|
|
@ -60,6 +115,7 @@ export type OverviewServer = {
|
|||
ownerId: string;
|
||||
image: string;
|
||||
channels: Channel[];
|
||||
invites: Invite[];
|
||||
};
|
||||
export type OverviewGroup = {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ export async function validateSessionToken(token: string) {
|
|||
id: table.server.id,
|
||||
name: table.server.name,
|
||||
ownerId: table.server.owner,
|
||||
channels: table.server.channels
|
||||
channels: table.server.channels,
|
||||
invites: table.server.invites
|
||||
})
|
||||
.from(table.server)
|
||||
.where(inArray(table.server.id, user.servers as string[]))
|
||||
|
|
@ -104,6 +105,7 @@ export async function validateSessionToken(token: string) {
|
|||
await Promise.all(
|
||||
(z.channels as string[]).map(async (m) => {
|
||||
const channel = await db.select().from(table.channel).where(eq(table.channel.id, m));
|
||||
//@TODO check if user can view channel
|
||||
if (!channel || channel.length == 0) return;
|
||||
return {
|
||||
name: channel[0].name,
|
||||
|
|
@ -111,6 +113,32 @@ export async function validateSessionToken(token: string) {
|
|||
};
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
//@TODO check if user can view all invites (limit only to user's own invites if you can't)
|
||||
invites: (
|
||||
await Promise.all(
|
||||
(z.invites as string[]).map(async (m) => {
|
||||
const invite = await db.select().from(table.invite).where(eq(table.invite.id, m));
|
||||
if (!invite || invite.length == 0) return;
|
||||
return {
|
||||
maxUses: invite[0].maxUses,
|
||||
id: invite[0].id,
|
||||
code: invite[0].code,
|
||||
uses: (invite[0].uses as string[]).length,
|
||||
createdAt: new Date(invite[0].createdAt),
|
||||
createdBy: await db
|
||||
.select()
|
||||
.from(table.user)
|
||||
.where(eq(table.user.id, invite[0].creatorId))
|
||||
.then((user) => {
|
||||
return {
|
||||
username: user[0].username,
|
||||
id: user[0].id
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
)
|
||||
).filter(Boolean)
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { GroupID, ServerID, statuses, UserID, type Channel, type OverviewData } from '$lib';
|
||||
import {
|
||||
GroupID,
|
||||
ServerID,
|
||||
statuses,
|
||||
UserID,
|
||||
type Channel,
|
||||
type Invite,
|
||||
type OverviewData
|
||||
} from '$lib';
|
||||
import type { PageServerData } from '../routes/app/$types';
|
||||
|
||||
export const overview_data: OverviewData = $state({
|
||||
|
|
@ -14,7 +22,8 @@ export async function fill_overview_data(data: PageServerData) {
|
|||
name: z.name,
|
||||
ownerId: z.ownerId,
|
||||
channels: z.channels as Channel[],
|
||||
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
|
||||
invites: z.invites as Invite[],
|
||||
image: 'https://api.dicebear.com/9.x/glass/svg?seed=' + z.name
|
||||
};
|
||||
});
|
||||
overview_data.groups = data.user.groups.map((z) => {
|
||||
|
|
@ -24,7 +33,7 @@ export async function fill_overview_data(data: PageServerData) {
|
|||
ownerId: z.ownerId,
|
||||
members: z.members,
|
||||
permissions: z.permissions,
|
||||
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
|
||||
image: 'https://api.dicebear.com/9.x/glass/svg?seed=' + z.name
|
||||
};
|
||||
});
|
||||
overview_data.friends = await Promise.all(
|
||||
|
|
@ -46,7 +55,7 @@ export async function fill_overview_data(data: PageServerData) {
|
|||
id: UserID.parse(friend.id),
|
||||
username: friend.username,
|
||||
dmId: friend.dmId,
|
||||
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + friend.username
|
||||
image: 'https://api.dicebear.com/9.x/glass/svg/svg?seed=' + friend.username
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||
? kvStore.get('user-' + member.id + '-message')
|
||||
: '',
|
||||
username: member.username,
|
||||
image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}`
|
||||
image: `https://api.dicebear.com/9.x/glass/svg?seed=${member.username}`
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||
? kvStore.get('user-' + member.id + '-message')
|
||||
: '',
|
||||
username: member.username,
|
||||
image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}`
|
||||
image: `https://api.dicebear.com/9.x/glass/svg?seed=${member.username}`
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
type OverviewData,
|
||||
GroupID,
|
||||
UserID,
|
||||
ServerID,
|
||||
|
|
@ -19,17 +18,14 @@
|
|||
import type { PageServerData } from './$types';
|
||||
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import type { ActionData } from './$types';
|
||||
import { formatTimestamp } from '$lib/utils';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
|
||||
import ServerDashboard from './ServerDashboard.svelte';
|
||||
import SendHorizontal from '@lucide/svelte/icons/send-horizontal';
|
||||
import PersonStanding from '@lucide/svelte/icons/person-standing';
|
||||
import MemberSidebar from '$lib/components/member-sidebar.svelte';
|
||||
|
|
@ -43,8 +39,6 @@
|
|||
let currentPage: OverviewUser | OverviewGroup | OverviewServer | undefined = $state();
|
||||
let currentSubPage: Channel | null = $state(null);
|
||||
|
||||
let inviteCode = $state();
|
||||
|
||||
let sse: EventSource | undefined;
|
||||
let messagesElement: HTMLDivElement | undefined = $state();
|
||||
let isMembersTabOpen = $state(true);
|
||||
|
|
@ -219,80 +213,6 @@
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
type FakePermission =
|
||||
| 'changeChannelName'
|
||||
| 'changeServerName'
|
||||
| 'createInvite'
|
||||
| 'createChannels'
|
||||
| 'deleteChannels'
|
||||
| 'deleteInvites'
|
||||
| 'addRoles'
|
||||
| 'deleteRoles'
|
||||
| 'kickPeople'
|
||||
| 'banPeople';
|
||||
|
||||
type FakeRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: Record<FakePermission, boolean>;
|
||||
};
|
||||
let newRoleName = $state('');
|
||||
|
||||
let fakeRoles = $state<FakeRole[]>([
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Admin',
|
||||
permissions: {
|
||||
changeChannelName: true,
|
||||
changeServerName: true,
|
||||
createInvite: true,
|
||||
createChannels: true,
|
||||
deleteChannels: true,
|
||||
deleteInvites: true,
|
||||
addRoles: true,
|
||||
deleteRoles: true,
|
||||
kickPeople: true,
|
||||
banPeople: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'moderator',
|
||||
name: 'Moderator',
|
||||
permissions: {
|
||||
changeChannelName: true,
|
||||
changeServerName: false,
|
||||
createInvite: true,
|
||||
createChannels: false,
|
||||
deleteChannels: true,
|
||||
deleteInvites: true,
|
||||
addRoles: true,
|
||||
deleteRoles: true,
|
||||
kickPeople: true,
|
||||
banPeople: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'member',
|
||||
name: 'Member',
|
||||
permissions: {
|
||||
changeChannelName: false,
|
||||
changeServerName: false,
|
||||
createInvite: true,
|
||||
createChannels: false,
|
||||
deleteChannels: false,
|
||||
deleteInvites: false,
|
||||
addRoles: false,
|
||||
deleteRoles: false,
|
||||
kickPeople: false,
|
||||
banPeople: false
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
let selectedRoleId = $state<string | null>(null);
|
||||
|
||||
let selectedRole = $derived(fakeRoles.find((r) => r.id === selectedRoleId));
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider>
|
||||
|
|
@ -347,205 +267,11 @@
|
|||
</header>
|
||||
|
||||
{#if currentPageID && currentPage && ServerID.is(currentPageID) && !currentSubPageID}
|
||||
{@const server = currentPage as OverviewServer}
|
||||
|
||||
<Tabs.Root value="roles" class="max-w-[800px] p-2">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="roles">Roles</Tabs.Trigger>
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="roles" class="space-y-6 p-4">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium">Roles (fake)</h2>
|
||||
<div class="flex gap-2">
|
||||
<Input placeholder="New role name" class="h-8 w-40" bind:value={newRoleName} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
if (!newRoleName) return;
|
||||
const newRole: FakeRole = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newRoleName,
|
||||
permissions: {
|
||||
changeChannelName: false,
|
||||
changeServerName: false,
|
||||
createInvite: false,
|
||||
createChannels: false,
|
||||
deleteChannels: false,
|
||||
deleteInvites: false,
|
||||
addRoles: false,
|
||||
deleteRoles: false,
|
||||
kickPeople: false,
|
||||
banPeople: false
|
||||
}
|
||||
};
|
||||
fakeRoles = [...fakeRoles, newRole];
|
||||
selectedRoleId = newRole.id;
|
||||
newRoleName = '';
|
||||
}}
|
||||
>
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each fakeRoles as role (role.id)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedRoleId === role.id ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="gap-1"
|
||||
onclick={() => (selectedRoleId = role.id)}
|
||||
>
|
||||
{role.name}
|
||||
{#if role.id !== 'member'}
|
||||
<button
|
||||
type="button"
|
||||
class="text-destructive hover:text-destructive/80"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
fakeRoles = fakeRoles.filter((r) => r.id !== role.id);
|
||||
if (selectedRoleId === role.id) selectedRoleId = null;
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedRole}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{selectedRole.name} permissions</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{#each Object.entries(selectedRole.permissions) as [perm, enabled] (perm)}
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>
|
||||
{#if perm === 'changeChannelName'}Change channel name{/if}
|
||||
{#if perm === 'changeServerName'}Change server name{/if}
|
||||
{#if perm === 'createInvite'}Create invite{/if}
|
||||
{#if perm === 'createChannels'}Create channels{/if}
|
||||
{#if perm === 'deleteChannels'}Delete channels{/if}
|
||||
{#if perm === 'deleteInvites'}Delete invites{/if}
|
||||
{#if perm === 'addRoles'}Add roles{/if}
|
||||
{#if perm === 'deleteRoles'}Delete roles{/if}
|
||||
{#if perm === 'kickPeople'}Kick members{/if}
|
||||
{#if perm === 'banPeople'}Ban members{/if}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onchange={(e) => {
|
||||
selectedRole.permissions[perm as FakePermission] = e.detail;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Changes are local only (no backend yet)
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="general" class="space-y-6 p-4">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-medium">Server Settings</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="New server name"
|
||||
class="max-w-xs"
|
||||
value={server?.name}
|
||||
oninput={(e) => {
|
||||
server.name = e.currentTarget.value;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => toast.info('Server name change would be saved (no backend yet)')}
|
||||
>
|
||||
Save Name
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Changes are local only (no backend yet)</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-medium">Invites</h2>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createInvite"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
toast.success('Invite created successfully');
|
||||
inviteCode = location.origin + '/invite/' + result.data!.code;
|
||||
await invalidateAll();
|
||||
await fill_overview_data(data);
|
||||
} else if (result.type === 'failure') {
|
||||
toast.error('Failed to create invite: ' + result.data?.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex gap-2"
|
||||
>
|
||||
<input type="hidden" name="serverId" value={currentPageID} />
|
||||
<div class="flex flex-1 gap-2">
|
||||
<Input placeholder="Create new invite" class="flex-1" readonly value={inviteCode} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm">Max uses:</Label>
|
||||
<Input type="number" name="maxUses" min="0" class="w-20" placeholder="10" />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">Create Invite</Button>
|
||||
</form>
|
||||
|
||||
<div class="space-y-2 pt-4">
|
||||
<h3 class="font-medium">Active Invites</h3>
|
||||
<div class="space-y-2 rounded border p-3">
|
||||
{#each server.invites || [] as invite (invite.code)}
|
||||
<div class="flex items-center justify-between rounded p-2 hover:bg-muted/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-mono">{invite.code}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{invite.uses}/{invite.maxUses || '∞'} uses • Created by {invite.creator} on {formatTimestamp(
|
||||
invite.createdAt
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={async () => {
|
||||
const response = await fetch(`/api/invites/${invite.code}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success('Invite deleted successfully');
|
||||
await invalidateAll();
|
||||
await fill_overview_data(data);
|
||||
} else {
|
||||
toast.error('Failed to delete invite');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<ServerDashboard
|
||||
bind:currentPage={currentPageID}
|
||||
psd={data}
|
||||
server={currentPage as OverviewServer}
|
||||
></ServerDashboard>
|
||||
{/if}
|
||||
{#if currentPageID && currentPage && ((ServerID.is(currentPageID) && ChannelID.is(currentSubPageID)) || UserID.is(currentPageID) || GroupID.is(currentPageID))}
|
||||
<div class="h-min shrink overflow-y-scroll" bind:this={messagesElement}>
|
||||
|
|
@ -553,7 +279,7 @@
|
|||
{#if i === 0 || messages[i - 1].authorId !== message.authorId}
|
||||
<div class="flex gap-2 px-4 py-2">
|
||||
<img
|
||||
src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + message.author.name}
|
||||
src={'https://api.dicebear.com/9.x/glass/svg?seed=' + message.author.name}
|
||||
alt={message.author.name}
|
||||
class="h-6 w-6 rounded-full"
|
||||
/>
|
||||
|
|
|
|||
331
src/routes/app/ServerDashboard.svelte
Normal file
331
src/routes/app/ServerDashboard.svelte
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<script lang="ts">
|
||||
import type { OverviewServer } from '$lib';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { fill_overview_data } from '$lib/state.svelte';
|
||||
import { formatTimestamp } from '$lib/utils';
|
||||
import type { PageServerData } from './$types';
|
||||
|
||||
let inviteCode = $state();
|
||||
|
||||
type FakePermission =
|
||||
| 'changeChannelName'
|
||||
| 'changeServerName'
|
||||
| 'createInvite'
|
||||
| 'createChannels'
|
||||
| 'deleteChannels'
|
||||
| 'deleteInvites'
|
||||
| 'addRoles'
|
||||
| 'deleteRoles'
|
||||
| 'kickPeople'
|
||||
| 'banPeople';
|
||||
|
||||
type FakeRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: Record<FakePermission, boolean>;
|
||||
};
|
||||
|
||||
let newRoleName = $state('');
|
||||
|
||||
let fakeRoles = $state<FakeRole[]>([
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Admin',
|
||||
permissions: {
|
||||
changeChannelName: true,
|
||||
changeServerName: true,
|
||||
createInvite: true,
|
||||
createChannels: true,
|
||||
deleteChannels: true,
|
||||
deleteInvites: true,
|
||||
addRoles: true,
|
||||
deleteRoles: true,
|
||||
kickPeople: true,
|
||||
banPeople: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'moderator',
|
||||
name: 'Moderator',
|
||||
permissions: {
|
||||
changeChannelName: true,
|
||||
changeServerName: false,
|
||||
createInvite: true,
|
||||
createChannels: false,
|
||||
deleteChannels: true,
|
||||
deleteInvites: true,
|
||||
addRoles: true,
|
||||
deleteRoles: true,
|
||||
kickPeople: true,
|
||||
banPeople: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'member',
|
||||
name: 'Member',
|
||||
permissions: {
|
||||
changeChannelName: false,
|
||||
changeServerName: false,
|
||||
createInvite: true,
|
||||
createChannels: false,
|
||||
deleteChannels: false,
|
||||
deleteInvites: false,
|
||||
addRoles: false,
|
||||
deleteRoles: false,
|
||||
kickPeople: false,
|
||||
banPeople: false
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
let selectedRoleId = $state<string | null>(null);
|
||||
|
||||
let selectedRole = $derived(fakeRoles.find((r) => r.id === selectedRoleId));
|
||||
|
||||
const {
|
||||
server,
|
||||
psd,
|
||||
currentPage = $bindable()
|
||||
}: {
|
||||
server: OverviewServer;
|
||||
psd: PageServerData;
|
||||
currentPage: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Tabs.Root value="roles" class="max-w-200 p-2">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="roles">Roles</Tabs.Trigger>
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="dangerous">Dangerous</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="dangerous" class="space-y-6 p-4">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-medium text-destructive">Danger Zone</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
These actions are irreversible and will affect all members of the server.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteServer"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
toast.success('Server deleted successfully');
|
||||
await invalidateAll();
|
||||
await fill_overview_data(psd);
|
||||
} else if (result.type === 'failure') {
|
||||
toast.error('Failed to delete server: ' + result.data?.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex items-center gap-2 pt-2"
|
||||
>
|
||||
<input type="hidden" name="serverId" value={currentPage} />
|
||||
<Button type="submit" variant="destructive">Delete Server</Button>
|
||||
<p class="text-sm text-destructive">
|
||||
This will permanently delete the server and all its data.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="roles" class="space-y-6 p-4">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium">Roles (fake)</h2>
|
||||
<div class="flex gap-2">
|
||||
<Input placeholder="New role name" class="h-8 w-40" bind:value={newRoleName} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
if (!newRoleName) return;
|
||||
const newRole: FakeRole = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newRoleName,
|
||||
permissions: {
|
||||
changeChannelName: false,
|
||||
changeServerName: false,
|
||||
createInvite: false,
|
||||
createChannels: false,
|
||||
deleteChannels: false,
|
||||
deleteInvites: false,
|
||||
addRoles: false,
|
||||
deleteRoles: false,
|
||||
kickPeople: false,
|
||||
banPeople: false
|
||||
}
|
||||
};
|
||||
fakeRoles = [...fakeRoles, newRole];
|
||||
selectedRoleId = newRole.id;
|
||||
newRoleName = '';
|
||||
}}
|
||||
>
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each fakeRoles as role (role.id)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedRoleId === role.id ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
class="gap-1"
|
||||
onclick={() => (selectedRoleId = role.id)}
|
||||
>
|
||||
{role.name}
|
||||
{#if role.id !== 'member'}
|
||||
<button
|
||||
type="button"
|
||||
class="text-destructive hover:text-destructive/80"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
fakeRoles = fakeRoles.filter((r) => r.id !== role.id);
|
||||
if (selectedRoleId === role.id) selectedRoleId = null;
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedRole}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{selectedRole.name} permissions</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{#each Object.entries(selectedRole.permissions) as [perm, enabled] (perm)}
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>
|
||||
{#if perm === 'changeChannelName'}Change channel name{/if}
|
||||
{#if perm === 'changeServerName'}Change server name{/if}
|
||||
{#if perm === 'createInvite'}Create invite{/if}
|
||||
{#if perm === 'createChannels'}Create channels{/if}
|
||||
{#if perm === 'deleteChannels'}Delete channels{/if}
|
||||
{#if perm === 'deleteInvites'}Delete invites{/if}
|
||||
{#if perm === 'addRoles'}Add roles{/if}
|
||||
{#if perm === 'deleteRoles'}Delete roles{/if}
|
||||
{#if perm === 'kickPeople'}Kick members{/if}
|
||||
{#if perm === 'banPeople'}Ban members{/if}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onchange={(e) => {
|
||||
selectedRole.permissions[perm as FakePermission] = e.detail;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">Changes are local only (no backend yet)</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="general" class="space-y-6 p-4">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-medium">Server Settings</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="New server name"
|
||||
class="max-w-xs"
|
||||
value={server?.name}
|
||||
oninput={(e) => {
|
||||
server.name = e.currentTarget.value;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => toast.info('Server name change would be saved (no backend yet)')}
|
||||
>
|
||||
Save Name
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Changes are local only (no backend yet)</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-medium">Invites</h2>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createInvite"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
toast.success('Invite created successfully');
|
||||
inviteCode = location.origin + '/invite/' + result.data!.code;
|
||||
await invalidateAll();
|
||||
await fill_overview_data(psd);
|
||||
} else if (result.type === 'failure') {
|
||||
toast.error('Failed to create invite: ' + result.data?.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex gap-2"
|
||||
>
|
||||
<input type="hidden" name="serverId" value={currentPage} />
|
||||
<div class="flex flex-1 gap-2">
|
||||
<Input placeholder="Create new invite" class="flex-1" readonly value={inviteCode} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm">Max uses:</Label>
|
||||
<Input type="number" name="maxUses" min="0" class="w-20" placeholder="10" />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">Create Invite</Button>
|
||||
</form>
|
||||
|
||||
<div class="space-y-2 pt-4">
|
||||
<h3 class="font-medium">Active Invites</h3>
|
||||
<div class="space-y-2 rounded border p-3">
|
||||
{#each server.invites || [] as invite (invite.code)}
|
||||
<div class="flex items-center justify-between rounded p-2 hover:bg-muted/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-mono">{invite.code}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{invite.uses}/{invite.maxUses || '∞'} uses • Created by {invite.createdBy
|
||||
.username} on {formatTimestamp(invite.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteInvite"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
toast.success('Invite deleted successfully');
|
||||
await invalidateAll();
|
||||
await fill_overview_data(psd);
|
||||
} else if (result.type === 'failure') {
|
||||
toast.error('Failed to delete invite: ' + result.data?.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="m-0"
|
||||
>
|
||||
<input type="hidden" name="inviteCode" value={invite.code} />
|
||||
<input type="hidden" name="serverId" value={currentPage} />
|
||||
<Button variant="destructive" size="sm" type="submit">Delete</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
|
@ -120,7 +120,7 @@ export default {
|
|||
code,
|
||||
creatorId: locals.user!.id,
|
||||
createdAt: new Date(),
|
||||
maxUses: maxUses <= 0 ? undefined : maxUses // if maxUses is not undefined, there are infnite uses
|
||||
maxUses: maxUses <= 0 ? undefined : maxUses // if maxUses is not undefined, there are infinite uses
|
||||
});
|
||||
|
||||
await tx
|
||||
|
|
@ -132,6 +132,45 @@ export default {
|
|||
});
|
||||
return { success: true, code };
|
||||
},
|
||||
deleteInvite: async ({ request, locals }) => {
|
||||
const data = await request.formData();
|
||||
const inviteCode = data.get('inviteCode');
|
||||
const serverId = data.get('serverId');
|
||||
|
||||
if (typeof inviteCode !== 'string' || typeof serverId !== 'string') {
|
||||
return fail(400, { error: 'Invalid invite data' });
|
||||
}
|
||||
|
||||
const server = await db.select().from(table.server).where(eq(table.server.id, serverId));
|
||||
if (!server || server.length == 0) {
|
||||
return fail(400, { error: 'Server ID invalid' });
|
||||
}
|
||||
|
||||
const invRaw = await db
|
||||
.select()
|
||||
.from(table.invite)
|
||||
.where(eq(table.invite.code, inviteCode))
|
||||
.limit(1);
|
||||
const invite = invRaw[0];
|
||||
|
||||
if (!invite) {
|
||||
return fail(400, { error: 'Invite not found' });
|
||||
}
|
||||
|
||||
// @TODO check permissions here (if owner ignore, then check roles, if any role has manageInvites then let delete invite)
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(table.server)
|
||||
.set({
|
||||
invites: (server[0].invites as string[]).filter((id) => id !== invite.id)
|
||||
})
|
||||
.where(eq(table.server.id, serverId));
|
||||
|
||||
await tx.delete(table.invite).where(eq(table.invite.id, invite.id));
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
createServer: async ({ request, locals }) => {
|
||||
const data = await request.formData();
|
||||
const name = data.get('name');
|
||||
|
|
@ -152,5 +191,47 @@ export default {
|
|||
|
||||
_sendToUser(locals.user!.id, { type: 'server', status: 'server-created' });
|
||||
return { success: true };
|
||||
},
|
||||
deleteServer: async ({ request, locals }) => {
|
||||
const data = await request.formData();
|
||||
const serverId = data.get('serverId');
|
||||
|
||||
if (typeof serverId !== 'string') {
|
||||
return fail(400, { error: 'Server ID incorrect' });
|
||||
}
|
||||
|
||||
const server = await db.select().from(table.server).where(eq(table.server.id, serverId));
|
||||
if (!server || server.length == 0) {
|
||||
return fail(400, { error: 'Server ID invalid' });
|
||||
}
|
||||
|
||||
// @TODO check permissions here (only owner should be able to delete server)
|
||||
|
||||
// Remove server from all members' server lists
|
||||
await db.transaction(async (tx) => {
|
||||
for (const memberId of server[0].members as string[]) {
|
||||
await tx
|
||||
.update(table.user)
|
||||
.set({
|
||||
servers: (
|
||||
(
|
||||
await tx
|
||||
.select({ servers: table.user.servers })
|
||||
.from(table.user)
|
||||
.where(eq(table.user.id, memberId))
|
||||
)[0].servers as string[]
|
||||
).filter((id: string) => id !== serverId)
|
||||
})
|
||||
.where(eq(table.user.id, memberId));
|
||||
}
|
||||
|
||||
await tx.delete(table.invite).where(eq(table.invite.serverId, serverId));
|
||||
|
||||
await tx.delete(table.channel).where(eq(table.channel.serverId, serverId));
|
||||
|
||||
await tx.delete(table.server).where(eq(table.server.id, serverId));
|
||||
});
|
||||
_sendToUser(locals.user!.id, { type: 'server', status: 'server-deleted' });
|
||||
return { success: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue