feat: Message context menus
This commit is contained in:
parent
8706b3eef6
commit
c7b46233b2
@ -25,6 +25,7 @@ io.on('connection', async (socket) => {
|
||||
const sender = authdb.getUser(msg.id);
|
||||
io!.emit('message', {
|
||||
user: sender.username,
|
||||
uid: msg.id,
|
||||
message: msg.content,
|
||||
imageSrc: sender.image,
|
||||
channel: msg.channel,
|
||||
|
@ -2,33 +2,58 @@
|
||||
import { type TypeMessage } from '$lib/types';
|
||||
import Prose from '$lib/components/prose.svelte';
|
||||
import renderMarkdown from '$lib/functions/renderMarkdown';
|
||||
const { message, imageSrc, user, timestamp }: TypeMessage = $props();
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
|
||||
const { message, imageSrc, user, timestamp, uid }: TypeMessage = $props();
|
||||
let epoch: number = Math.floor(timestamp.getTime() / 1000);
|
||||
|
||||
function copy(itemName: string, content: string | number) {
|
||||
navigator.clipboard
|
||||
.writeText(content as string)
|
||||
.then(() => {
|
||||
console.info(`Successfully copied ${itemName} to clipboard`);
|
||||
// dispatchToast('Successfully copied to clipboard.', true);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Error copying ${itemName}: ${(e as Error).message}`);
|
||||
// dispatchToast('Copying failed. (See console)', false);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full p-2 hover:bg-zinc-200 dark:hover:bg-stone-900">
|
||||
<div class="avatar mr-2 rounded-sm">
|
||||
<div class="h-12 w-12 overflow-hidden rounded-lg border bg-white">
|
||||
<img src={imageSrc} alt="Profile image for {user}" />
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger class="flex w-full p-2 hover:bg-zinc-200 dark:hover:bg-stone-900">
|
||||
<div class="avatar mr-2 rounded-sm">
|
||||
<div class="h-12 w-12 overflow-hidden rounded-lg border bg-white">
|
||||
<img src={imageSrc} alt="Profile image for {user}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<p class="inline-size-full flex items-center gap-1 break-words">
|
||||
<span class="font-bold">{user}</span>
|
||||
<span>·</span>
|
||||
<span class="text-muted-foreground"
|
||||
>{timestamp.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour12: true,
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})}</span
|
||||
>
|
||||
</p>
|
||||
<Prose class="inline-size-full text-wrap break-words font-sans">{@html renderMarkdown(message)}</Prose>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<p class="inline-size-full flex items-center gap-1 break-words">
|
||||
<span class="font-bold">{user}</span>
|
||||
<span>·</span>
|
||||
<span class="text-muted-foreground"
|
||||
>{timestamp.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour12: true,
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})}</span
|
||||
>
|
||||
</p>
|
||||
<Prose class="inline-size-full text-wrap break-words font-sans">{@html renderMarkdown(message)}</Prose>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('username', user)}>Copy Username</ContextMenu.Item>
|
||||
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('user ID', uid)}>Copy User ID</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('message', message)}>Copy message content</ContextMenu.Item>
|
||||
<ContextMenu.Item class="cursor-pointer" onclick={() => copy('timestamp', epoch)}>Copy message epoch</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<style>
|
||||
.inline-size-full {
|
||||
|
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import Check from 'svelte-radix/Check.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.CheckboxItemProps;
|
||||
type $$Events = ContextMenuPrimitive.CheckboxItemEvents;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
export let checked: $$Props['checked'] = undefined;
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
bind:checked
|
||||
class={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
on:focusin
|
||||
on:focusout
|
||||
on:pointerdown
|
||||
on:pointerleave
|
||||
on:pointermove
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.CheckboxIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
</ContextMenuPrimitive.CheckboxIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import { cn, flyAndScale } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.ContentProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = flyAndScale;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Content
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn('z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none', className)}
|
||||
{...$$restProps}
|
||||
on:keydown
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuPrimitive.Content>
|
31
src/lib/components/ui/context-menu/context-menu-item.svelte
Normal file
31
src/lib/components/ui/context-menu/context-menu-item.svelte
Normal file
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
};
|
||||
type $$Events = ContextMenuPrimitive.ItemEvents;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let inset: $$Props['inset'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Item
|
||||
class={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
on:focusin
|
||||
on:focusout
|
||||
on:pointerdown
|
||||
on:pointerleave
|
||||
on:pointermove
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuPrimitive.Item>
|
16
src/lib/components/ui/context-menu/context-menu-label.svelte
Normal file
16
src/lib/components/ui/context-menu/context-menu-label.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.LabelProps & {
|
||||
inset?: boolean;
|
||||
};
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let inset: $$Props['inset'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Label class={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</ContextMenuPrimitive.Label>
|
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.RadioGroupProps;
|
||||
|
||||
export let value: $$Props['value'] = undefined;
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioGroup {...$$restProps} bind:value>
|
||||
<slot />
|
||||
</ContextMenuPrimitive.RadioGroup>
|
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import DotFilled from 'svelte-radix/DotFilled.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.RadioItemProps;
|
||||
type $$Events = ContextMenuPrimitive.RadioItemEvents;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let value: $$Props['value'];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
class={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{value}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
on:focusin
|
||||
on:focusout
|
||||
on:pointerdown
|
||||
on:pointerleave
|
||||
on:pointermove
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.RadioIndicator>
|
||||
<DotFilled class="h-4 w-4 fill-current" />
|
||||
</ContextMenuPrimitive.RadioIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</ContextMenuPrimitive.RadioItem>
|
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.SeparatorProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Separator class={cn('-mx-1 my-1 h-px bg-border', className)} {...$$restProps} />
|
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</span>
|
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import { cn, flyAndScale } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.SubContentProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let transition: $$Props['transition'] = flyAndScale;
|
||||
export let transitionConfig: $$Props['transitionConfig'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.SubContent
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn('z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none', className)}
|
||||
{...$$restProps}
|
||||
on:keydown
|
||||
on:focusout
|
||||
on:pointermove
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuPrimitive.SubContent>
|
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
import ChevronRight from 'svelte-radix/ChevronRight.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type $$Props = ContextMenuPrimitive.SubTriggerProps & {
|
||||
inset?: boolean;
|
||||
};
|
||||
type $$Events = ContextMenuPrimitive.SubTriggerEvents;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export let inset: $$Props['inset'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
class={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
on:focusin
|
||||
on:focusout
|
||||
on:pointerleave
|
||||
on:pointermove
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
49
src/lib/components/ui/context-menu/index.ts
Normal file
49
src/lib/components/ui/context-menu/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
|
||||
|
||||
import Item from './context-menu-item.svelte';
|
||||
import Label from './context-menu-label.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 RadioGroup from './context-menu-radio-group.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';
|
||||
|
||||
const Sub = ContextMenuPrimitive.Sub;
|
||||
const Root = ContextMenuPrimitive.Root;
|
||||
const Trigger = ContextMenuPrimitive.Trigger;
|
||||
const Group = ContextMenuPrimitive.Group;
|
||||
|
||||
export {
|
||||
Sub,
|
||||
Root,
|
||||
Item,
|
||||
Label,
|
||||
Group,
|
||||
Trigger,
|
||||
Content,
|
||||
Shortcut,
|
||||
Separator,
|
||||
RadioItem,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
RadioGroup,
|
||||
CheckboxItem,
|
||||
//
|
||||
Root as ContextMenu,
|
||||
Sub as ContextMenuSub,
|
||||
Item as ContextMenuItem,
|
||||
Label as ContextMenuLabel,
|
||||
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,
|
||||
};
|
@ -33,6 +33,7 @@ export function startupSocketIOServer(httpServer: HttpServer | null) {
|
||||
const sender = authdb.getUser(msg.id);
|
||||
io!.emit('message', {
|
||||
user: sender.username,
|
||||
uid: msg.id,
|
||||
message: msg.content,
|
||||
imageSrc: sender.image,
|
||||
channel: msg.channel,
|
||||
|
@ -2,6 +2,7 @@ export interface TypeMessage {
|
||||
message: string;
|
||||
imageSrc: string;
|
||||
user: string;
|
||||
uid: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@ -10,5 +11,6 @@ export interface TypeFullMessage {
|
||||
message: string;
|
||||
imageSrc: string;
|
||||
user: string;
|
||||
uid: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ export async function load({ params, request }): Promise<ChannelLoad> {
|
||||
return {
|
||||
message: value.message_content,
|
||||
user: sender.username,
|
||||
uid: value.sender,
|
||||
imageSrc: sender.image,
|
||||
channel: value.channel,
|
||||
timestamp: new Date(value.timestamp),
|
||||
|
@ -70,8 +70,8 @@
|
||||
</svelte:head>
|
||||
|
||||
{#snippet message(messages: TypeMessage[])}
|
||||
{#each messages as message}
|
||||
<Message imageSrc={message.imageSrc} user={message.user} message={message.message} timestamp={message.timestamp} />
|
||||
{#each messages as message, i}
|
||||
<Message imageSrc={message.imageSrc} user={message.user} message={message.message} timestamp={message.timestamp} uid={message.uid} />
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user