channel creation / deletion

This commit is contained in:
Soph :3 2026-01-17 00:39:00 +02:00
parent f1dcd0cfc5
commit d0509788ff
19 changed files with 577 additions and 28 deletions

View file

@ -11,6 +11,7 @@
import UsersRound from '@lucide/svelte/icons/users-round';
import CirclePlus from '@lucide/svelte/icons/circle-plus';
import Input from './ui/input/input.svelte';
import * as ContextMenu from '$lib/components/ui/context-menu/index.js';
import Button, { buttonVariants } from './ui/button/button.svelte';
import User from './extra/User.svelte';
import type { SessionValidationResult } from '$lib/server/auth';
@ -482,7 +483,10 @@
<Collapsible.Content>
<Sidebar.MenuSub>
{#each overview_data.servers as server (server.id)}
<Sidebar.MenuSubItem>
<Dialog.Root>
<ContextMenu.Root>
<ContextMenu.Trigger
><Sidebar.MenuSubItem>
<Sidebar.MenuSubButton
onclick={(e) => {
e.preventDefault();
@ -498,7 +502,67 @@
{server.name}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item>
<Dialog.Trigger>Create channel</Dialog.Trigger>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<Dialog.Content>
<form
method="POST"
action="?/createChannel"
use:enhance={() => {
return async ({ result }) => {
if (result.type == 'success') {
toast.success('Created channel successfully');
}
if (result.type == 'error' || result.type == 'failure') {
toast.error(
'Could not create channel: ' +
(result.type === 'error' ? result.error : result.data?.error)
);
}
await invalidateAll();
await fill_overview_data(psd);
};
}}
>
<Dialog.Header>
<Dialog.Title>Create a channel</Dialog.Title>
<Dialog.Description>Add a new channel to this server.</Dialog.Description>
</Dialog.Header>
<div class="space-y-1">
<Label for="channelName">Channel name</Label>
<Input
id="channelName"
name="channelName"
placeholder="Channel name"
required
minlength={1}
maxlength={32}
/>
<input type="hidden" name="serverId" value={server.id} />
</div>
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}
>Cancel</Dialog.Close
>
<Button type="submit">Create</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
{#each server.channels as channel (channel.id)}
<Dialog.Root>
<ContextMenu.Root>
<ContextMenu.Trigger>
<a
onclick={(e) => {
e.preventDefault();
@ -510,6 +574,56 @@
>
{channel.name}
</a>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item class="text-red-500 underline">
<Dialog.Trigger>Delete channel</Dialog.Trigger>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<Dialog.Content>
<form
method="POST"
action="?/deleteChannel"
use:enhance={() => {
return async ({ result }) => {
if (result.type == 'success') {
toast.success('Deleted channel successfully');
}
if (result.type == 'error' || result.type == 'failure') {
toast.error(
'Could not delete channel: ' +
(result.type === 'error' ? result.error : result.data?.error)
);
}
await invalidateAll();
await fill_overview_data(psd);
};
}}
>
<input type="hidden" name="serverId" value={server.id} />
<input type="hidden" name="channelId" value={channel.id} />
<Dialog.Header>
<Dialog.Title>Delete this channel</Dialog.Title>
<Dialog.Description
>This action is IRREVERSIBLE and will delete all messages in the
channel. Are you sure you want to proceed?</Dialog.Description
>
</Dialog.Header>
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}
>No</Dialog.Close
>
<Button variant="destructive" type="submit">Yes</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
{/each}
{/each}
</Sidebar.MenuSub>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="context-menu-checkbox-item"
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if checked}
<CheckIcon class="size-4" />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import ContextMenuPortal from "./context-menu-portal.svelte";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
portalProps,
class: className,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
} = $props();
</script>
<ContextMenuPortal {...portalProps}>
<ContextMenuPrimitive.Content
bind:ref
data-slot="context-menu-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--bits-context-menu-content-available-height) min-w-[8rem] origin-(--bits-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...restProps}
/>
</ContextMenuPortal>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
data-slot="context-menu-group-heading"
data-inset={inset}
class={cn("text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:ps-8", className)}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props();
</script>
<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<ContextMenuPrimitive.Item
bind:ref
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="context-menu-label"
data-inset={inset}
class={cn("text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:ps-8", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props();
</script>
<ContextMenuPrimitive.Portal {...restProps} />

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(""),
...restProps
}: ContextMenuPrimitive.RadioGroupProps = $props();
</script>
<ContextMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="context-menu-radio-group"
{...restProps}
/>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
data-slot="context-menu-radio-item"
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
>
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
data-slot="context-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="context-menu-shortcut"
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
data-slot="context-menu-sub-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--bits-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
data-slot="context-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ms-auto" />
</ContextMenuPrimitive.SubTrigger>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps = $props();
</script>
<ContextMenuPrimitive.Sub bind:open {...restProps} />

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.TriggerProps = $props();
</script>
<ContextMenuPrimitive.Trigger bind:ref data-slot="context-menu-trigger" {...restProps} />

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.RootProps = $props();
</script>
<ContextMenuPrimitive.Root bind:open {...restProps} />

View file

@ -0,0 +1,52 @@
import Root from "./context-menu.svelte";
import Sub from "./context-menu-sub.svelte";
import Portal from "./context-menu-portal.svelte";
import Trigger from "./context-menu-trigger.svelte";
import Group from "./context-menu-group.svelte";
import RadioGroup from "./context-menu-radio-group.svelte";
import Item from "./context-menu-item.svelte";
import GroupHeading from "./context-menu-group-heading.svelte";
import Content from "./context-menu-content.svelte";
import Shortcut from "./context-menu-shortcut.svelte";
import RadioItem from "./context-menu-radio-item.svelte";
import Separator from "./context-menu-separator.svelte";
import SubContent from "./context-menu-sub-content.svelte";
import SubTrigger from "./context-menu-sub-trigger.svelte";
import CheckboxItem from "./context-menu-checkbox-item.svelte";
import Label from "./context-menu-label.svelte";
export {
Root,
Sub,
Portal,
Item,
GroupHeading,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Portal as ContextMenuPortal,
Item as ContextMenuItem,
GroupHeading as ContextMenuGroupHeading,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem,
Label as ContextMenuLabel,
};

View file

@ -1,7 +1,7 @@
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { InviteID, ServerID } from '$lib';
import { ChannelID, InviteID, ServerID } from '$lib';
import { eq } from 'drizzle-orm';
import { _sendToUser } from '../../api/updates/+server';
import type { Actions } from '../$types';
@ -192,6 +192,79 @@ export default {
_sendToUser(locals.user!.id, { type: 'server', status: 'server-created' });
return { success: true };
},
createChannel: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('channelName');
const serverId = data.get('serverId');
if (typeof name !== 'string' || name.length < 1 || name.length > 32) {
return fail(400, { error: 'Channel name must be between 1-32 characters' });
}
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 (if owner ignore, then check roles, if any role has manageChannels then let create channel)
const channelId = ChannelID.newV4();
await db.transaction(async (tx) => {
await tx.insert(table.channel).values({
id: channelId,
serverId: serverId,
name
});
await tx
.update(table.server)
.set({
channels: (server[0].channels as string[]).concat([channelId])
})
.where(eq(table.server.id, serverId));
});
return { success: true };
},
deleteChannel: async ({ request, locals }) => {
const data = await request.formData();
const serverId = data.get('serverId');
const channelId = data.get('channelId');
if (typeof serverId !== 'string' || typeof channelId !== 'string') {
return fail(400, { error: 'Invalid channel 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 channel = await db.select().from(table.channel).where(eq(table.channel.id, channelId));
if (!channel || channel.length == 0) {
return fail(400, { error: 'Channel not found' });
}
// @TODO check permissions here (if owner ignore, then check roles, if any role has manageChannels then let delete channel)
await db.transaction(async (tx) => {
await tx.delete(table.channel).where(eq(table.channel.id, channelId));
await tx
.update(table.server)
.set({
channels: (server[0].channels as string[]).filter((id) => id !== channelId)
})
.where(eq(table.server.id, serverId));
});
return { success: true };
},
deleteServer: async ({ request, locals }) => {
const data = await request.formData();
const serverId = data.get('serverId');