feat: chat

This commit is contained in:
glazk0 2025-01-29 12:29:45 +01:00
parent b00559128d
commit aa4a7cd2d3
No known key found for this signature in database
11 changed files with 320 additions and 28 deletions

View file

@ -26,7 +26,8 @@
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "1.0.0-next.78", "bits-ui": "1.0.0-next.79",
"clsx": "^2.1.1",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^2.46.1",
@ -38,6 +39,7 @@
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.10", "prettier-plugin-tailwindcss": "^0.6.10",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-boring-avatars": "^1.2.6",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1", "sveltekit-superforms": "^2.23.1",
@ -49,8 +51,6 @@
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.20.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^3.0.0", "vitest": "^3.0.0",
"svelte-boring-avatars": "^1.2.6",
"clsx": "^2.1.1",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"dependencies": { "dependencies": {

18
pnpm-lock.yaml generated
View file

@ -43,8 +43,8 @@ importers:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.4.20(postcss@8.5.1) version: 10.4.20(postcss@8.5.1)
bits-ui: bits-ui:
specifier: 1.0.0-next.78 specifier: 1.0.0-next.79
version: 1.0.0-next.78(svelte@5.19.3) version: 1.0.0-next.79(svelte@5.19.3)
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@ -813,8 +813,8 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'} engines: {node: '>=8'}
bits-ui@1.0.0-next.78: bits-ui@1.0.0-next.79:
resolution: {integrity: sha512-jZjG2ObZ/CNyCNaXecpItC7hRXqJAgEfMhr06/eNrf3wHiiPyhdcy4OkzLcJyxeOrDyj+xma8cZTd3JRWqJdAw==} resolution: {integrity: sha512-k8cLe5LTTpssBJJ3VdRl9fHa9BFKZAe01xZ/jT3CZArgbq+XqOiQBvhPXwMYV/SKK3EvaOl7lxw13GI61tGxHA==}
engines: {node: '>=18', pnpm: '>=8.7.0'} engines: {node: '>=18', pnpm: '>=8.7.0'}
peerDependencies: peerDependencies:
svelte: ^5.11.0 svelte: ^5.11.0
@ -1664,8 +1664,8 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.7.0 svelte: ^5.7.0
runed@0.22.0: runed@0.23.1:
resolution: {integrity: sha512-ZWVXWhOr0P5xdNgtviz6D1ivLUDWKLCbeC5SUEJ3zBkqLReVqWHenFxMNFeFaiC5bfxhFxyxzyzB+98uYFtwdA==} resolution: {integrity: sha512-h1ZDmin0LBoSMEZxvHOJbCWsUBz4099cjI+/rQ4FZystgOq294s5Rh+OEeu9HIObc8XQQEya23eAhJeal0VBuA==}
peerDependencies: peerDependencies:
svelte: ^5.7.0 svelte: ^5.7.0
@ -2709,13 +2709,13 @@ snapshots:
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bits-ui@1.0.0-next.78(svelte@5.19.3): bits-ui@1.0.0-next.79(svelte@5.19.3):
dependencies: dependencies:
'@floating-ui/core': 1.6.9 '@floating-ui/core': 1.6.9
'@floating-ui/dom': 1.6.13 '@floating-ui/dom': 1.6.13
'@internationalized/date': 3.7.0 '@internationalized/date': 3.7.0
esm-env: 1.2.2 esm-env: 1.2.2
runed: 0.22.0(svelte@5.19.3) runed: 0.23.1(svelte@5.19.3)
svelte: 5.19.3 svelte: 5.19.3
svelte-toolbelt: 0.7.0(svelte@5.19.3) svelte-toolbelt: 0.7.0(svelte@5.19.3)
@ -3489,7 +3489,7 @@ snapshots:
esm-env: 1.2.2 esm-env: 1.2.2
svelte: 5.19.3 svelte: 5.19.3
runed@0.22.0(svelte@5.19.3): runed@0.23.1(svelte@5.19.3):
dependencies: dependencies:
esm-env: 1.2.2 esm-env: 1.2.2
svelte: 5.19.3 svelte: 5.19.3

View file

@ -6,6 +6,7 @@
import GitBranch from 'lucide-svelte/icons/git-branch'; import GitBranch from 'lucide-svelte/icons/git-branch';
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard'; import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
import LogOut from 'lucide-svelte/icons/log-out'; import LogOut from 'lucide-svelte/icons/log-out';
import Radio from 'lucide-svelte/icons/radio';
import ScrollText from 'lucide-svelte/icons/scroll-text'; import ScrollText from 'lucide-svelte/icons/scroll-text';
import Settings from 'lucide-svelte/icons/settings'; import Settings from 'lucide-svelte/icons/settings';
@ -61,6 +62,12 @@
url: '/admin/puzzles', url: '/admin/puzzles',
icon: Code icon: Code
} }
,
{
title: 'Broadcast',
url: '/admin/broadcast',
icon: Radio
}
] ]
} }
]); ]);

View file

@ -0,0 +1,18 @@
import { Tabs as TabsPrimitive } from "bits-ui";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
const Root = TabsPrimitive.Root;
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
class={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
class={cn(
"bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
class={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,62 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_URL } from "$env/static/private";
import type { Chapter, Group } from "$lib/types";
import { error, fail, redirect } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user || !locals.user.email.endsWith('@peerat.dev')) {
redirect(302, '/');
}
let chapters: Chapter[] = [];
let res = await fetch(API_URL + "/chapters");
if (res.ok) {
chapters = await res.json();
} else {
console.error("Error fetching chapters");
}
const event = chapters?.filter((chapter) => chapter.start && chapter.end).pop();
if (!event) {
error(404);
}
res = await fetch(`${API_URL}/groups/${event.id}`);
if (!res.ok) redirect(302, "/chapters/" + event.id);
const groups: Group[] = await res.json();
return {
title: "Broadcast" + " - " + event.name,
event,
groups,
};
};
export const actions: Actions = {
default: async ({ request, locals, fetch }) => {
if (!locals.user || !locals.user.email.endsWith('@peerat.dev')) {
return fail(401);
}
const data = await request.formData();
const message = data.get('message') as string;
const group = data.get('group') as string;
const body = group.length ? { group, message } : { message };
const res = await fetch(API_URL + "/admin/event/", {
method: "POST",
body: JSON.stringify(body),
});
console.log(res);
},
};

View file

@ -0,0 +1,97 @@
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
import type { EventHandler } from 'svelte/elements';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import * as Tabs from '$lib/components/ui/tabs';
let { data }: PageProps = $props();
let messages = $state({}) as Record<string, string[]>;
let group = $state('');
let message = $state('');
const handleSubmit: EventHandler<SubmitEvent, HTMLFormElement> = function (event) {
if (!group.length) {
const global = messages['global'];
if (!global) {
messages = {
...messages,
global: [message]
};
} else {
messages = {
...messages,
global: [...global, message]
};
}
} else {
const groupMessages = messages[group];
if (!groupMessages) {
messages = {
...messages,
[group]: [message]
};
} else {
messages = {
...messages,
[group]: [...groupMessages, message]
};
}
}
};
</script>
<h2>Peer-at Broadcaster for {data.event.name}</h2>
<form class="flex items-center gap-2" method="POST" use:enhance onsubmit={handleSubmit}>
<Select.Root type="single" name="group" bind:value={group}>
<Select.Trigger class="w-[180px]">
{#if group}
{group}
{:else}
Global
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Global</Select.Item>
{#each data.groups as group}
<Select.Item value={group.name}>{group.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Input bind:value={message} name="message" placeholder="Message" />
<Button type="submit">Envoyer</Button>
</form>
<Tabs.Root value="global" class="w-[400px]">
<Tabs.List>
<Tabs.Trigger value="global">Global</Tabs.Trigger>
{#each data.groups as group}
<Tabs.Trigger value={group.name}>{group.name}</Tabs.Trigger>
{/each}
</Tabs.List>
<Tabs.Content value="global">
<ul>
{#each messages['global'] as message}
<li>{message}</li>
{/each}
</ul>
</Tabs.Content>
{#each data.groups as group}
<Tabs.Content value={group.name}>
<ul>
{#each messages[group.name] as message}
<li>{message}</li>
{/each}
</ul>
</Tabs.Content>
{/each}
</Tabs.Root>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { page } from '$app/state';
import { onDestroy, onMount } from 'svelte';
import { derived } from 'svelte/store';
import { createStateStore } from '$lib/stores/state';
import { connectWebSocket } from '$lib/stores/websocket';
type Broadcast = {
message: string;
};
const stateStore = createStateStore<Broadcast>();
onMount(() => {
connectWebSocket('/group/event/', stateStore, { group: page.params.name });
return () => stateStore.reset();
});
const broadcastsStore = derived(stateStore, ($stateStore) => $stateStore.requests);
let broadcasts: Broadcast[] = $state([]);
const unsubscribe = broadcastsStore.subscribe(async (value) => {
broadcasts = value;
});
onDestroy(unsubscribe);
</script>
<section class="flex w-full flex-col gap-2">
<header class="flex flex-col justify-between gap-2 lg:flex-row lg:items-center">
<div class="flex flex-col">
<h2 class="text-xl font-semibold">Peer-at Communication</h2>
<p class="text-muted-foreground">
Ici vous pouvez voir les messages envoyés par les administrateurs
</p>
</div>
</header>
<ul class="flex flex-col gap-2">
{#if broadcasts.length === 0}
<li class="flex min-h-16 w-full flex-col rounded border border-border bg-card p-4">
<span class="text-muted-foreground">Aucun message</span>
</li>
{/if}
{#each broadcasts as broadcast, i (i)}
<li class="flex min-h-16 w-full flex-col rounded border border-border bg-card p-4">
<span class="text-muted-foreground">Message d'un peerat</span>
<p>{broadcast.message}</p>
</li>
{/each}
</ul>
</section>

View file

@ -4,13 +4,13 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import Loader from 'lucide-svelte/icons/loader-circle';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters'; import { zodClient } from 'sveltekit-superforms/adapters';
import Loader from 'lucide-svelte/icons/loader-circle';
import * as Form from '$lib/components/ui/form'; import * as Form from '$lib/components/ui/form';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import * as InputOTP from '$lib/components/ui/input-otp';
import { formConfirmationSchema, formSchema } from './schema'; import { formConfirmationSchema, formSchema } from './schema';
@ -29,9 +29,9 @@
onResult: ({ result }) => { onResult: ({ result }) => {
switch (result.type) { switch (result.type) {
case 'success': case 'success':
// toast.message('Demande de confirmation', { toast.message('Demande de confirmation', {
// description: `Un code vous à été ${confirmation ? 'renvoyé' : 'envoyé'}.` description: `Un code vous à été ${confirmation ? 'renvoyé' : 'envoyé'}.`
// }); });
formConfirmationData.set({ formConfirmationData.set({
...$formData, ...$formData,
passwd: '', passwd: '',
@ -48,10 +48,14 @@
const formConfirmation = superForm(data.formConfirmation, { const formConfirmation = superForm(data.formConfirmation, {
validators: zodClient(formConfirmationSchema), validators: zodClient(formConfirmationSchema),
delayMs: 500, delayMs: 500
}); });
let { form: formConfirmationData, enhance: formConfirmationEnhance, delayed: formConfirmationDelayed } = formConfirmation; let {
form: formConfirmationData,
enhance: formConfirmationEnhance,
delayed: formConfirmationDelayed
} = formConfirmation;
</script> </script>
{#if !confirmation} {#if !confirmation}
@ -193,15 +197,7 @@
<Form.Control> <Form.Control>
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Code de confirmation</Form.Label> <Form.Label>Code de confirmation</Form.Label>
<InputOTP.Root {...props} maxlength={9} bind:value={$formConfirmationData.code}> <Input {...props} bind:value={$formConfirmationData.code} placeholder="00fa-00fa" />
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
{/snippet} {/snippet}
</Form.Control> </Form.Control>
<Form.FieldErrors /> <Form.FieldErrors />