feat: chat
This commit is contained in:
parent
b00559128d
commit
aa4a7cd2d3
11 changed files with 320 additions and 28 deletions
|
@ -26,7 +26,8 @@
|
|||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"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-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
|
@ -38,6 +39,7 @@
|
|||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.10",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-boring-avatars": "^1.2.6",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"sveltekit-superforms": "^2.23.1",
|
||||
|
@ -49,8 +51,6 @@
|
|||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"svelte-boring-avatars": "^1.2.6",
|
||||
"clsx": "^2.1.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
|
@ -43,8 +43,8 @@ importers:
|
|||
specifier: ^10.4.20
|
||||
version: 10.4.20(postcss@8.5.1)
|
||||
bits-ui:
|
||||
specifier: 1.0.0-next.78
|
||||
version: 1.0.0-next.78(svelte@5.19.3)
|
||||
specifier: 1.0.0-next.79
|
||||
version: 1.0.0-next.79(svelte@5.19.3)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
|
@ -813,8 +813,8 @@ packages:
|
|||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bits-ui@1.0.0-next.78:
|
||||
resolution: {integrity: sha512-jZjG2ObZ/CNyCNaXecpItC7hRXqJAgEfMhr06/eNrf3wHiiPyhdcy4OkzLcJyxeOrDyj+xma8cZTd3JRWqJdAw==}
|
||||
bits-ui@1.0.0-next.79:
|
||||
resolution: {integrity: sha512-k8cLe5LTTpssBJJ3VdRl9fHa9BFKZAe01xZ/jT3CZArgbq+XqOiQBvhPXwMYV/SKK3EvaOl7lxw13GI61tGxHA==}
|
||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
svelte: ^5.11.0
|
||||
|
@ -1664,8 +1664,8 @@ packages:
|
|||
peerDependencies:
|
||||
svelte: ^5.7.0
|
||||
|
||||
runed@0.22.0:
|
||||
resolution: {integrity: sha512-ZWVXWhOr0P5xdNgtviz6D1ivLUDWKLCbeC5SUEJ3zBkqLReVqWHenFxMNFeFaiC5bfxhFxyxzyzB+98uYFtwdA==}
|
||||
runed@0.23.1:
|
||||
resolution: {integrity: sha512-h1ZDmin0LBoSMEZxvHOJbCWsUBz4099cjI+/rQ4FZystgOq294s5Rh+OEeu9HIObc8XQQEya23eAhJeal0VBuA==}
|
||||
peerDependencies:
|
||||
svelte: ^5.7.0
|
||||
|
||||
|
@ -2709,13 +2709,13 @@ snapshots:
|
|||
|
||||
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:
|
||||
'@floating-ui/core': 1.6.9
|
||||
'@floating-ui/dom': 1.6.13
|
||||
'@internationalized/date': 3.7.0
|
||||
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-toolbelt: 0.7.0(svelte@5.19.3)
|
||||
|
||||
|
@ -3489,7 +3489,7 @@ snapshots:
|
|||
esm-env: 1.2.2
|
||||
svelte: 5.19.3
|
||||
|
||||
runed@0.22.0(svelte@5.19.3):
|
||||
runed@0.23.1(svelte@5.19.3):
|
||||
dependencies:
|
||||
esm-env: 1.2.2
|
||||
svelte: 5.19.3
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
import GitBranch from 'lucide-svelte/icons/git-branch';
|
||||
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
|
||||
import LogOut from 'lucide-svelte/icons/log-out';
|
||||
import Radio from 'lucide-svelte/icons/radio';
|
||||
import ScrollText from 'lucide-svelte/icons/scroll-text';
|
||||
import Settings from 'lucide-svelte/icons/settings';
|
||||
|
||||
|
@ -61,6 +62,12 @@
|
|||
url: '/admin/puzzles',
|
||||
icon: Code
|
||||
}
|
||||
,
|
||||
{
|
||||
title: 'Broadcast',
|
||||
url: '/admin/broadcast',
|
||||
icon: Radio
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
|
18
src/lib/components/ui/tabs/index.ts
Normal file
18
src/lib/components/ui/tabs/index.ts
Normal 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,
|
||||
};
|
19
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
19
src/lib/components/ui/tabs/tabs-content.svelte
Normal 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}
|
||||
/>
|
19
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
19
src/lib/components/ui/tabs/tabs-list.svelte
Normal 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}
|
||||
/>
|
19
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
19
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal 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}
|
||||
/>
|
62
src/routes/(app)/admin/broadcast/+page.server.ts
Normal file
62
src/routes/(app)/admin/broadcast/+page.server.ts
Normal 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);
|
||||
},
|
||||
};
|
97
src/routes/(app)/admin/broadcast/+page.svelte
Normal file
97
src/routes/(app)/admin/broadcast/+page.svelte
Normal 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>
|
|
@ -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>
|
|
@ -4,13 +4,13 @@
|
|||
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import Loader from 'lucide-svelte/icons/loader-circle';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import Loader from 'lucide-svelte/icons/loader-circle';
|
||||
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as InputOTP from '$lib/components/ui/input-otp';
|
||||
|
||||
import { formConfirmationSchema, formSchema } from './schema';
|
||||
|
||||
|
@ -29,9 +29,9 @@
|
|||
onResult: ({ result }) => {
|
||||
switch (result.type) {
|
||||
case 'success':
|
||||
// toast.message('Demande de confirmation', {
|
||||
// description: `Un code vous à été ${confirmation ? 'renvoyé' : 'envoyé'}.`
|
||||
// });
|
||||
toast.message('Demande de confirmation', {
|
||||
description: `Un code vous à été ${confirmation ? 'renvoyé' : 'envoyé'}.`
|
||||
});
|
||||
formConfirmationData.set({
|
||||
...$formData,
|
||||
passwd: '',
|
||||
|
@ -48,10 +48,14 @@
|
|||
|
||||
const formConfirmation = superForm(data.formConfirmation, {
|
||||
validators: zodClient(formConfirmationSchema),
|
||||
delayMs: 500,
|
||||
delayMs: 500
|
||||
});
|
||||
|
||||
let { form: formConfirmationData, enhance: formConfirmationEnhance, delayed: formConfirmationDelayed } = formConfirmation;
|
||||
let {
|
||||
form: formConfirmationData,
|
||||
enhance: formConfirmationEnhance,
|
||||
delayed: formConfirmationDelayed
|
||||
} = formConfirmation;
|
||||
</script>
|
||||
|
||||
{#if !confirmation}
|
||||
|
@ -193,15 +197,7 @@
|
|||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Code de confirmation</Form.Label>
|
||||
<InputOTP.Root {...props} maxlength={9} bind:value={$formConfirmationData.code}>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells as cell}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
{/snippet}
|
||||
</InputOTP.Root>
|
||||
<Input {...props} bind:value={$formConfirmationData.code} placeholder="00fa-00fa" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
|
|
Loading…
Add table
Reference in a new issue