1
0

semifinished

This commit is contained in:
2024-05-21 05:44:31 +03:00
parent 4bd5e9fe52
commit 170c19594e
94 changed files with 2678 additions and 290 deletions

View File

@@ -1,46 +1,55 @@
import { messagesCache, channelsCache } from '$lib/stores/cache';
import type { Channel, Message } from '../types';
import { messagesCache, channelsCache, channelsUserCache } from '$lib/stores/cache';
import type { Channel, ChannelUserPermissions, Message } from '../types';
import { apiRequest } from './utils';
export async function getAllChannels() {
return await apiRequest<Channel[]>('/channel', 'get', undefined, (data) => {
export async function getAllChannels(token?: string) {
return await apiRequest<Channel[]>('/channel', 'get', { token }, (data) => {
data.forEach((channel) => {
channelsCache.set(channel.id, channel);
});
});
}
export async function getChannelById(channelId: number) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'get', undefined, (data) => {
export async function getChannelById(channelId: number, token?: string) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'get', { token }, (data) => {
channelsCache.set(data.id, data);
});
}
export async function createChannel(name: string) {
return await apiRequest<Channel>('/channel', 'post', { data: { name } }, (data) => {
export async function createChannel(name: string, token?: string) {
return await apiRequest<Channel>('/channel', 'post', { data: { name }, token }, (data) => {
channelsCache.set(data.id, data);
});
}
export async function deleteChannel(channelId: number) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'delete', undefined, (data) => {
export async function deleteChannel(channelId: number, token?: string) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'delete', { token }, (data) => {
channelsCache.remove(data.id);
channelsUserCache.remove(data.id);
});
}
export async function addUserToChannel(channelId: number, userId: number) {
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'post');
export async function addUserToChannel(channelId: number, userId: number, token?: string) {
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'post', { token }, async () => {
const channelUsers = await channelsUserCache.get(channelId) || new Set();
channelUsers.add(userId);
channelsUserCache.set(channelId, channelUsers);
});
}
export async function removeUserFromChannel(channelId: number, userId: number) {
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'delete');
export async function removeUserFromChannel(channelId: number, userId: number, token?: string) {
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'delete', { token }, async () => {
const channelUsers = await channelsUserCache.get(channelId) || new Set();
channelUsers.delete(userId);
channelsUserCache.set(channelId, channelUsers);
});
}
export async function getMessagesByChannelId(channelId: number, beforeId?: number, limit?: number) {
export async function getMessagesByChannelId(channelId: number, beforeId?: number, limit?: number, token?: string) {
return await apiRequest<Message[]>(
`/channel/${channelId}/message${beforeId || limit ? '?' : ''}${beforeId ? `before=${beforeId}` : ''}${limit ? `&limit=${limit}` : ''}`,
`/channel/${channelId}/message`,
'get',
undefined,
{ params: { before: beforeId, limit }, token },
(data) => {
data.forEach((message) => {
messagesCache.set(message.id, message);
@@ -48,3 +57,7 @@ export async function getMessagesByChannelId(channelId: number, beforeId?: numbe
}
);
}
export async function getChannelUserPermissions(channelId: number, userId: number, token?: string) {
return await apiRequest<ChannelUserPermissions>(`/channel/${channelId}/users/${userId}/permissions`, 'get', { token });
}

View File

@@ -0,0 +1,26 @@
import { notificationsCache } from "$lib/stores/cache";
import type { Notification } from "$lib/types";
import { apiRequest } from "./utils";
export async function getNotificationById(notificationId: number, token?: string) {
return await apiRequest<Notification>(`/notification/${notificationId}`, 'get', { token }, (data) => {
notificationsCache.set(data.id, data);
});
}
export async function getAllNotifications(limit?: number, offset?: number, token?: string) {
return await apiRequest<Notification[]>('/notification', 'get', { params: { limit, offset }, token }, (data) => {
data.forEach((notification) => {
notificationsCache.set(notification.id, notification);
});
});
}
export async function seenNotification(notificationId: number, token?: string) {
return await apiRequest<unknown>(`/notification/${notificationId}`, 'post', { token }, async () => {
const notification = await notificationsCache.get(notificationId);
if (notification) {
notificationsCache.set(notificationId, { ...notification, seen: true });
}
});
}

52
src/lib/api/secret.ts Normal file
View File

@@ -0,0 +1,52 @@
import { secretCache, usersCache } from "$lib/stores/cache";
import type { Secret, User } from "$lib/types";
import { apiRequest } from "./utils";
export async function getSecretById(secretId: number, token?: string) {
return await apiRequest<Secret>(`/secret/${secretId}`, 'get', { token }, (data) => {
secretCache.set(data.id, data);
});
}
export async function getSecretsBySelf(token?: string) {
return await apiRequest<Secret[]>(`/secret`, 'get', { token }, (data) => {
data.forEach((secret) => {
secretCache.set(secret.id, secret);
});
});
}
export async function createSecret(name: string, content: string, timeoutSeconds: number, recipients: number[], token?: string) {
return await apiRequest<Secret>('/secret', 'post', { data: { name, content, timeoutSeconds, recipients }, token }, (data) => {
secretCache.set(data.id, data);
});
}
export async function updateSecret(secretId: number, name: string, content: string, timeoutSeconds: number, recipients: number[], token?: string) {
return await apiRequest<Secret>(`/secret/${secretId}`, 'put', { data: { name, content, timeoutSeconds, recipients }, token }, (data) => {
secretCache.set(data.id, data);
});
}
export async function deleteSecret(secretId: number, token?: string) {
return await apiRequest<Secret>(`/secret/${secretId}`, 'delete', { token }, (data) => {
secretCache.remove(data.id);
});
}
export async function addSecretRecipient(secretId: number, recipientId: number, token?: string) {
return await apiRequest<unknown>(`/secret/${secretId}/recipient/${recipientId}`, 'post', { token });
}
export async function removeSecretRecipient(secretId: number, recipientId: number, token?: string) {
return await apiRequest<unknown>(`/secret/${secretId}/recipient/${recipientId}`, 'delete', { token });
}
export async function getSecretRecipients(secretId: number, token?: string) {
return await apiRequest<User[]>(`/secret/${secretId}/recipients`, 'get', { token }, (data) => {
data.forEach((user) => {
usersCache.set(user.id, user);
});
});
}

View File

@@ -1,4 +1,4 @@
import { usersCache } from '$lib/stores/cache';
import { channelsUserCache, followingUserCache, usersCache } from '$lib/stores/cache';
import type { Token, User } from '../types';
import { apiRequest } from './utils';
@@ -32,3 +32,47 @@ export async function loginUser(username: string, password: string) {
export async function registerUser(username: string, password: string) {
return await apiRequest<Token>('/user/register', 'post', { data: { username, password } });
}
export async function getFollowedUsers(token?: string) {
return await apiRequest<User[]>('/user/me/follow', 'get', { token }, (data) => {
data.forEach((user) => {
usersCache.set(user.id, user);
followingUserCache.set(user.id, true);
});
});
}
export async function isFollowing(userId: number) {
return await apiRequest<boolean>(`/user/${userId}/follow`, 'get', undefined, data => {
followingUserCache.set(userId, data);
});
}
export async function followUser(userId: number) {
return await apiRequest<unknown>(`/user/${userId}/follow`, 'post', undefined, () => {
followingUserCache.set(userId, true);
});
}
export async function unfollowUser(userId: number) {
return await apiRequest<unknown>(`/user/${userId}/follow`, 'delete', undefined, () => {
followingUserCache.set(userId, false);
});
}
export async function getUsersByChannelId(channelId: number, token?: string) {
return await apiRequest<User[]>(`/channel/${channelId}/users`, 'get', { token }, (data) => {
data.forEach((user) => {
usersCache.set(user.id, user);
});
channelsUserCache.set(channelId, new Set(data.map((user) => user.id)));
});
}
export async function searchUsersByUsername(query: string, limit?: number, offset?: number, token?: string) {
return await apiRequest<User[]>(`/user/search`, 'get', { params: { query, limit, offset }, token }, (data) => {
data.forEach((user) => {
usersCache.set(user.id, user);
});
});
}

View File

@@ -12,7 +12,7 @@ export async function apiRequest<T>(
): Promise<T | ErrorResponse> {
const url = API_URL + path;
console.log(`[API] ${method.toUpperCase()} ${url}`);
console.log(`[API] ${method.toUpperCase()} ${url} `, JSON.stringify(options?.data));
const token = options?.token || getUserToken();

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import * as ContextMenu from '$lib/components/ui/context-menu';
import * as Command from '$lib/components/ui/command';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import * as Dialog from '$lib/components/ui/dialog';
import {
isErrorResponse,
type Channel,
type ChannelUserPermissions,
type User
} from '$lib/types';
import { Trash2, UserMinus, UserPlus } from 'lucide-svelte';
import {
addUserToChannel,
deleteChannel,
getChannelUserPermissions,
removeUserFromChannel
} from '$lib/api/channel';
import { getFollowedUsers, getUsersByChannelId } from '$lib/api/user';
import UserSearch from './user-search.svelte';
import { user as userStore } from '$lib/stores/user';
import { channelsUserCache, usersCache } from '$lib/stores/cache';
export let channel: Channel;
let deleteChannelDialogOpen = false;
let addUserDialogOpen = false;
let kickMemberDialogOpen = false;
let toAddUsers: User[] = [];
function handleInvite() {
getFollowedUsers().then((followed_users) => {
if (!isErrorResponse(followed_users)) {
getUsersByChannelId(channel.id).then((channel_users) => {
if (!isErrorResponse(channel_users)) {
toAddUsers = followed_users.filter(
(user) => !channel_users.some((u) => u.id === user.id)
);
}
});
}
});
addUserDialogOpen = true;
}
let permissions: ChannelUserPermissions | undefined = undefined;
$: {
if ($userStore) {
getChannelUserPermissions($userStore.id, channel.id).then((result) => {
if (!isErrorResponse(result)) {
permissions = result;
}
});
}
}
let channelUsers: User[] = [];
channelsUserCache.get(channel.id).then(async (users) => {
if (users) {
channelUsers = [];
for (const userId of users) {
if (userId === $userStore?.id) continue;
const user = await usersCache.get(userId);
if (user) channelUsers.push(user);
}
}
});
channelsUserCache.subscribeKey_autoDispose(channel.id, 'add', async (users) => {
if (users) {
channelUsers = [];
for (const userId of users) {
if (userId === $userStore?.id) continue;
const user = await usersCache.get(userId);
if (user) channelUsers.push(user);
}
}
});
channelsUserCache.subscribeKey_autoDispose(channel.id, 'update', async (users) => {
if (users) {
channelUsers = [];
for (const userId of users) {
if (userId === $userStore?.id) continue;
const user = await usersCache.get(userId);
if (user) channelUsers.push(user);
}
}
});
</script>
<AlertDialog.Root bind:open={deleteChannelDialogOpen}>
<AlertDialog.Trigger></AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Are you absolutely sure?</AlertDialog.Title>
<AlertDialog.Description>
This action <strong>cannot</strong> be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action on:click={() => deleteChannel(channel.id)}>
Continue
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<UserSearch
bind:open={addUserDialogOpen}
users={toAddUsers}
onSelect={(user) => addUserToChannel(channel.id, user.id)}
/>
<UserSearch
bind:open={kickMemberDialogOpen}
users={channelUsers}
onSelect={(user) => removeUserFromChannel(channel.id, user.id)}
/>
<!-- <Dialog.Root bind:open={addUserDialogOpen}>
<Dialog.Trigger></Dialog.Trigger>
<Dialog.Content>
<Command.Root>
<Command.Input placeholder="Type a name" />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
{#each toAddUsers as user}
<Command.Item
onSelect={() => {
addUserToChannel(channel.id, user.id);
addUserDialogOpen = false;
}}
>
{user.username}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Dialog.Content>
</Dialog.Root> -->
<ContextMenu.Root closeOnEscape={true}>
<ContextMenu.Trigger>
<slot />
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Label>
<span class="font-bold">{channel.name}</span>
</ContextMenu.Label>
<ContextMenu.Item on:click={() => handleInvite()}>
<UserPlus />
<span class="ml-2">Invite</span>
</ContextMenu.Item>
{#if permissions && permissions.admin}
<ContextMenu.Item on:click={() => (kickMemberDialogOpen = true)}>
<UserMinus />
<span class="ml-2">Kick member</span>
</ContextMenu.Item>
{/if}
{#if permissions && permissions.admin}
<ContextMenu.Separator />
<ContextMenu.Item on:click={() => (deleteChannelDialogOpen = true)}>
<Trash2 />
<span class="ml-2">Delete</span>
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { Bell } from 'lucide-svelte';
import { Button } from './ui/button';
import { type Notification } from '$lib/types';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/utils';
import { ScrollArea } from './ui/scroll-area';
import { Separator } from './ui/separator';
export let notifications: Notification[] = [];
export let onSeenNotification: (notification: Notification) => void = () => {};
let className = '';
export { className as class };
let open = false;
$: hasUnseenNotifications = notifications.some((notification) => !notification.seen) || false;
$: sortedNotifications = notifications.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
function onNotificationClick() {
if (!open) return;
for (const notification of notifications) {
if (!notification.seen) {
onSeenNotification(notification);
}
}
}
$: if (open) onNotificationClick();
</script>
<Popover.Root bind:open>
<Popover.Trigger>
<div class="">
<Button variant="ghost" class={cn('h-10 w-10 p-0', className)}>
{#if hasUnseenNotifications}
<div class="relative bottom-2 left-2 h-2 w-2 rounded-full bg-red-500" />
{/if}
<Bell class="bottom-1 right-14" />
</Button>
</div>
</Popover.Trigger>
{#if notifications.length > 0}
<Popover.Content class="h-full max-h-[300px] w-[400px]">
<ScrollArea class="h-full">
<div class="p-2 text-center text-xl font-bold">Notifications</div>
{#each sortedNotifications as notification}
<Separator />
<div class="p-2">
<div class="truncate text-lg font-bold">{notification.title}</div>
<div class="break-all text-sm">{notification.body}</div>
<div class="text-xs">
{new Date(notification.createdAt).toLocaleString()}
</div>
</div>
{/each}
</ScrollArea>
</Popover.Content>
{/if}
</Popover.Root>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
type $$Props = AlertDialogPrimitive.ActionProps;
type $$Events = AlertDialogPrimitive.ActionEvents;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<AlertDialogPrimitive.Action
class={cn(buttonVariants(), className)}
{...$$restProps}
on:click
on:keydown
let:builder
>
<slot {builder} />
</AlertDialogPrimitive.Action>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
type $$Props = AlertDialogPrimitive.CancelProps;
type $$Events = AlertDialogPrimitive.CancelEvents;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<AlertDialogPrimitive.Cancel
class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...$$restProps}
on:click
on:keydown
let:builder
>
<slot {builder} />
</AlertDialogPrimitive.Cancel>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import * as AlertDialog from "./index.js";
import { cn, flyAndScale } from "$lib/utils.js";
type $$Props = AlertDialogPrimitive.ContentProps;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialogPrimitive.Content
{transition}
{transitionConfig}
class={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full",
className
)}
{...$$restProps}
>
<slot />
</AlertDialogPrimitive.Content>
</AlertDialog.Portal>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = AlertDialogPrimitive.DescriptionProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<AlertDialogPrimitive.Description
class={cn("text-sm text-muted-foreground", className)}
{...$$restProps}
>
<slot />
</AlertDialogPrimitive.Description>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...$$restProps}
>
<slot />
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...$$restProps}>
<slot />
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { fade } from "svelte/transition";
import { cn } from "$lib/utils.js";
type $$Props = AlertDialogPrimitive.OverlayProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = fade;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 150,
};
export { className as class };
</script>
<AlertDialogPrimitive.Overlay
{transition}
{transitionConfig}
class={cn("fixed inset-0 z-50 bg-background/80 backdrop-blur-sm ", className)}
{...$$restProps}
/>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
type $$Props = AlertDialogPrimitive.PortalProps;
</script>
<AlertDialogPrimitive.Portal {...$$restProps}>
<slot />
</AlertDialogPrimitive.Portal>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = AlertDialogPrimitive.TitleProps;
let className: $$Props["class"] = undefined;
export let level: $$Props["level"] = "h3";
export { className as class };
</script>
<AlertDialogPrimitive.Title class={cn("text-lg font-semibold", className)} {level} {...$$restProps}>
<slot />
</AlertDialogPrimitive.Title>

View File

@@ -0,0 +1,40 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Trigger = AlertDialogPrimitive.Trigger;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import Check from "lucide-svelte/icons/check";
import Minus from "lucide-svelte/icons/minus";
import { cn } from "$lib/utils.js";
type $$Props = CheckboxPrimitive.Props;
type $$Events = CheckboxPrimitive.Events;
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = false;
export { className as class };
</script>
<CheckboxPrimitive.Root
class={cn(
"peer box-content h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[disabled=true]:opacity-50",
className
)}
bind:checked
{...$$restProps}
on:click
>
<CheckboxPrimitive.Indicator
class={cn("flex h-4 w-4 items-center justify-center text-current")}
let:isChecked
let:isIndeterminate
>
{#if isChecked}
<Check class="h-3.5 w-3.5" />
{:else if isIndeterminate}
<Minus class="h-3.5 w-3.5" />
{/if}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>

View File

@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { Dialog as DialogPrimitive } from "bits-ui";
import type { Command as CommandPrimitive } from "cmdk-sv";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
export let open: $$Props["open"] = false;
export let value: $$Props["value"] = undefined;
</script>
<Dialog.Root bind:open {...$$restProps}>
<Dialog.Content class="overflow-hidden p-0 shadow-lg">
<Command
class="[&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5"
{...$$restProps}
bind:value
>
<slot />
</Command>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
type $$Props = CommandPrimitive.EmptyProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
<slot />
</CommandPrimitive.Empty>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
type $$Props = CommandPrimitive.GroupProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Group
class={cn(
"overflow-hidden p-1 text-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group-heading]]:text-muted-foreground",
className
)}
{...$$restProps}
>
<slot />
</CommandPrimitive.Group>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import Search from "lucide-svelte/icons/search";
import { cn } from "$lib/utils.js";
type $$Props = CommandPrimitive.InputProps;
let className: string | undefined | null = undefined;
export { className as class };
export let value: string = "";
</script>
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
class={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
bind:value
/>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
type $$Props = CommandPrimitive.ItemProps;
export let asChild = false;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Item
{asChild}
class={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...$$restProps}
let:action
let:attrs
>
<slot {action} {attrs} />
</CommandPrimitive.Item>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
type $$Props = CommandPrimitive.ListProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.List
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...$$restProps}
>
<slot />
</CommandPrimitive.List>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
type $$Props = CommandPrimitive.SeparatorProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Separator class={cn("-mx-1 h-px bg-border", className)} {...$$restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLSpanElement>;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<span
class={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...$$restProps}
>
<slot />
</span>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils.js";
type $$Props = CommandPrimitive.CommandProps;
export let value: $$Props["value"] = undefined;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Root
class={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
bind:value
{...$$restProps}
>
<slot />
</CommandPrimitive.Root>

View File

@@ -0,0 +1,37 @@
import { Command as CommandPrimitive } from "cmdk-sv";
import Root from "./command.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import Check from "lucide-svelte/icons/check";
import { cn } from "$lib/utils.js";
type $$Props = ContextMenuPrimitive.CheckboxItemProps;
type $$Events = ContextMenuPrimitive.CheckboxItemEvents;
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = undefined;
export { className as class };
</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>

View File

@@ -0,0 +1,24 @@
<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>

View 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>

View File

@@ -0,0 +1,19 @@
<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>

View File

@@ -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>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import Circle from "lucide-svelte/icons/circle";
import { cn } from "$lib/utils.js";
type $$Props = ContextMenuPrimitive.RadioItemProps;
type $$Events = ContextMenuPrimitive.RadioItemEvents;
let className: $$Props["class"] = undefined;
export let value: ContextMenuPrimitive.RadioItemProps["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>
<Circle class="h-2 w-2 fill-current" />
</ContextMenuPrimitive.RadioIndicator>
</span>
<slot />
</ContextMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,14 @@
<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}
/>

View File

@@ -0,0 +1,16 @@
<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>

View File

@@ -0,0 +1,29 @@
<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"] = {
x: -10,
y: 0,
};
export { className as class };
</script>
<ContextMenuPrimitive.SubContent
{transition}
{transitionConfig}
class={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none",
className
)}
{...$$restProps}
on:keydown
on:focusout
on:pointermove
>
<slot />
</ContextMenuPrimitive.SubContent>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import ChevronRight from "lucide-svelte/icons/chevron-right";
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>

View 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,
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import X from "lucide-svelte/icons/x";
import * as Dialog from "./index.js";
import { cn, flyAndScale } from "$lib/utils.js";
type $$Props = DialogPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 200,
};
export { className as class };
</script>
<Dialog.Portal>
<Dialog.Overlay />
<DialogPrimitive.Content
{transition}
{transitionConfig}
class={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full",
className
)}
{...$$restProps}
>
<slot />
<DialogPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</Dialog.Portal>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = DialogPrimitive.DescriptionProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<DialogPrimitive.Description
class={cn("text-sm text-muted-foreground", className)}
{...$$restProps}
>
<slot />
</DialogPrimitive.Description>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...$$restProps}
>
<slot />
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...$$restProps}>
<slot />
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { fade } from "svelte/transition";
import { cn } from "$lib/utils.js";
type $$Props = DialogPrimitive.OverlayProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = fade;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 150,
};
export { className as class };
</script>
<DialogPrimitive.Overlay
{transition}
{transitionConfig}
class={cn("fixed inset-0 z-50 bg-background/80 backdrop-blur-sm", className)}
{...$$restProps}
/>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
type $$Props = DialogPrimitive.PortalProps;
</script>
<DialogPrimitive.Portal {...$$restProps}>
<slot />
</DialogPrimitive.Portal>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = DialogPrimitive.TitleProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<DialogPrimitive.Title
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</DialogPrimitive.Title>

View File

@@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import Title from "./dialog-title.svelte";
import Portal from "./dialog-portal.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger;
const Close = DialogPrimitive.Close;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { followUser, unfollowUser } from '$lib/api/user';
import * as ContextMenu from '$lib/components/ui/context-menu';
import { followingUserCache } from '$lib/stores/cache';
import type { User } from '$lib/types';
import { UserMinus, UserPlus } from 'lucide-svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
export let user: User;
let isFollowing = false;
// followingUserCache.get(user_id).then((value) => (isFollowing = value || false));
$: {
followingUserCache.get(user.id).then((value) => (isFollowing = value || false));
}
followingUserCache.subscribeKey_autoDispose(user.id, 'update', (data) => {
isFollowing = data || false;
});
function handleFollow() {
if (isFollowing) {
unfollowUser(user.id);
} else {
followUser(user.id);
}
}
let unfollowDialogOpen = false;
</script>
<AlertDialog.Root bind:open={unfollowDialogOpen}>
<AlertDialog.Trigger></AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Are you absolutely sure?</AlertDialog.Title>
<AlertDialog.Description>
This action <strong>cannot</strong> be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action on:click={() => unfollowUser(user.id)}>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<ContextMenu.Root closeOnEscape={false}>
<ContextMenu.Trigger>
<slot />
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Label>
<span class="font-bold">{user.username}</span>
</ContextMenu.Label>
{#if isFollowing}
<ContextMenu.Item on:click={() => (unfollowDialogOpen = true)}>
<UserMinus />
<span class="ml-2">Unfollow</span>
</ContextMenu.Item>
{:else}
<ContextMenu.Item on:click={() => followUser(user.id)}>
<UserPlus />
<span class="ml-2">Follow</span>
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import * as Command from '$lib/components/ui/command';
import * as Avatar from '$lib/components/ui/avatar';
import * as Dialog from '$lib/components/ui/dialog';
import type { User } from '$lib/types';
import { writable } from 'svelte/store';
export let open: boolean = false;
export let onSelect: (user: User) => void = () => {};
export let onQueryUpdate: ((query: string) => Promise<User[]>) | null = null;
export let delay: number = 250;
export let users: User[] = [];
let currentTimeoutId: number | undefined = undefined;
let value = writable('');
function handleInputEvent(query: string) {
if (!open) return;
if (currentTimeoutId) {
clearTimeout(currentTimeoutId);
}
if (!onQueryUpdate) return;
currentTimeoutId = setTimeout(() => {
onQueryUpdate(query).then((found) => {
users = [...found];
});
}, delay);
}
$: handleInputEvent($value);
$: if (!open && $value) {
if (currentTimeoutId) {
clearTimeout(currentTimeoutId);
}
value.set('');
users = [];
}
</script>
<Dialog.Root bind:open>
<Dialog.Trigger></Dialog.Trigger>
<Dialog.Content>
<Command.Root>
<Command.Input placeholder="Type a name" bind:value={$value} />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
{#each users as user}
<Command.Item
onSelect={() => {
onSelect(user);
open = false;
}}
>
<div class="flex items-center gap-2">
<Avatar.Root class="h-6 w-6">
<Avatar.Image src={user.avatar || '/default-avatar.png'} />
<Avatar.Fallback>{user.username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
{user.username}
</div>
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,15 +1,17 @@
import { onDestroy, onMount } from "svelte";
export interface Listener<D> {
(event: D): unknown;
}
export interface Disposable {
export interface ListenerDisposable {
dispose(): void;
}
export class EventEmitter<E, D> {
private listeners: Map<E, Listener<D>[]> = new Map();
on = (event: E, listener: Listener<D>): Disposable => {
on = (event: E, listener: Listener<D>): ListenerDisposable => {
if (!this.listeners.has(event)) this.listeners.set(event, []);
this.listeners.get(event)?.push(listener);
@@ -19,6 +21,18 @@ export class EventEmitter<E, D> {
};
};
on_autoDispose = (event: E, callback: (data: D) => void) => {
let disposable: ListenerDisposable;
onMount(() => {
disposable = this.on(event, callback);
})
onDestroy(() => {
disposable.dispose();
})
};
off = (event: E, listener: Listener<D>) => {
if (!this.listeners.has(event)) return;
@@ -33,7 +47,7 @@ export class EventEmitter<E, D> {
this.listeners.get(event)?.forEach((listener) => setTimeout(() => listener(data), 0));
};
pipe = (event: E, te: EventEmitter<E, D>): Disposable => {
pipe = (event: E, te: EventEmitter<E, D>): ListenerDisposable => {
return this.on(event, (e) => te.emit(event, e));
};
}

View File

@@ -1,23 +1,49 @@
import { getChannelById } from '$lib/api/channel';
import { getMessageById } from '$lib/api/message';
import { getUserById } from '$lib/api/user';
import { isErrorResponse, type Channel, type Message, type User } from '$lib/types';
import { getNotificationById } from '$lib/api/notification';
import { getSecretById, getSecretRecipients } from '$lib/api/secret';
import { getUserById, getUsersByChannelId, isFollowing } from '$lib/api/user';
import { dataOrNull, type Channel, type Message, type Secret, type User, type Notification } from '$lib/types';
import { Cache } from './utils';
export const usersCache: Cache<number, User> = new Cache(async (id) => {
export const usersCache: Cache<number, User> = new Cache('User', async (id) => {
const response = await getUserById(id);
if (isErrorResponse(response)) return null;
return response;
return dataOrNull(response);
});
export const messagesCache: Cache<number, Message> = new Cache(async (id) => {
export const messagesCache: Cache<number, Message> = new Cache('Message', async (id) => {
const response = await getMessageById(id);
if (isErrorResponse(response)) return null;
return response;
return dataOrNull(response);
});
export const channelsCache: Cache<number, Channel> = new Cache(async (id) => {
export const channelsCache: Cache<number, Channel> = new Cache('Channel', async (id) => {
const response = await getChannelById(id);
if (isErrorResponse(response)) return null;
return response;
return dataOrNull(response);
});
type Following = boolean;
export const followingUserCache: Cache<number, Following> = new Cache('Following', async (id) => {
const response = await isFollowing(id);
return dataOrNull(response);
});
export const secretCache: Cache<number, Secret> = new Cache('Secret', async (id) => {
const response = await getSecretById(id);
return dataOrNull(response);
});
export const secretRecipientsCache: Cache<number, Set<number>> = new Cache('SecretRecipients', async (id) => {
const response = await getSecretRecipients(id);
return new Set(dataOrNull(response)?.map((user) => user.id)) || null;
});
export const notificationsCache: Cache<number, Notification> = new Cache('Notifications', async (id) => {
const response = await getNotificationById(id);
return dataOrNull(response);
});
export const channelsUserCache: Cache<number, Set<number>> = new Cache('ChannelsUser', async (id) => {
const response = await getUsersByChannelId(id);
return new Set(dataOrNull(response)?.map((user) => user.id)) || null;
});

View File

@@ -1,19 +1,26 @@
import { EventEmitter } from '$lib/event';
import { get, writable, type Writable } from 'svelte/store';
export type CacheEvent = 'add' | 'update' | 'remove';
export class Cache<I, T> {
private data: Writable<Map<I, T>> = writable(new Map<I, T>());
private runningCaches: Set<I> = new Set();
private eventEmitter = new EventEmitter<CacheEvent, [I, T | null]>();
private name: string;
private resolver: (data: I) => Promise<T | null>;
constructor(resolver: (data: I) => Promise<T | null>) {
constructor(name: string, resolver: (data: I) => Promise<T | null>) {
this.name = name;
this.resolver = resolver;
}
async get(key: I): Promise<T | null> {
const cached = get(this.data).get(key);
if (cached) {
console.log(`[Cache] Found in cache: `, cached);
console.log(`[Cache] Found in cache ${key}/${this.name}: `, cached);
return cached;
}
@@ -34,24 +41,56 @@ export class Cache<I, T> {
this.runningCaches.delete(key);
if (data)
console.log(`[Cache] Added to cache: `, data);
return data;
}
set(key: I, value: T) {
console.log(`[Cache] Added to cache: `, value);
const data = get(this.data);
if (data.has(key)) {
console.log(`[Cache] Updated cache ${key}/${this.name}: `, value);
this.eventEmitter.emit('update', [key, value]);
}
else {
console.log(`[Cache] Added to cache ${key}/${this.name}: `, value);
this.eventEmitter.emit('add', [key, value]);
}
this.data.update((data) => data.set(key, value));
}
remove(key: I) {
console.log(`[Cache] Removed from cache: `, key);
console.log(`[Cache] Removed from cache ${key}/${this.name}: `, key);
this.data.update((data) => {
data.delete(key);
return data;
});
this.eventEmitter.emit('remove', [key, null]);
}
subscribe(event: CacheEvent, callback: (data: [I, T | null]) => void) {
return this.eventEmitter.on(event, callback);
}
subscribeKey(key: I, event: CacheEvent, callback: (data: T | null) => void) {
return this.subscribe(event, (data) => {
if (data[0] === key) callback(data[1]);
});
}
unsubscribe(event: CacheEvent, callback: (data: [I, T | null]) => void) {
return this.eventEmitter.off(event, callback);
}
subscribe_autoDispose(event: CacheEvent, callback: (data: [I, T | null]) => void) {
this.eventEmitter.on_autoDispose(event, callback);
}
subscribeKey_autoDispose(key: I, event: CacheEvent, callback: (data: T | null) => void) {
this.subscribe_autoDispose(event, (data) => {
if (data[0] === key) callback(data[1]);
});
}
}

View File

@@ -2,26 +2,56 @@ import { BASE_API_URL } from '$lib/constants';
import { EventEmitter } from '$lib/event';
import { derived, get } from 'svelte/store';
import { token as tokenStore } from './user';
import type { Channel, Message } from '$lib/types';
import { messagesCache, channelsCache } from './cache';
import type { Channel, Message, Secret, Notification } from '$lib/types';
import { messagesCache, channelsCache, channelsUserCache, secretCache, notificationsCache, followingUserCache } from './cache';
import { browser } from '$app/environment';
export type WebSocketMessageType =
| 'createMessage'
| 'updateChannel'
| 'createChannel'
| 'deleteChannel'
| 'addedUserToChannel'
| 'removedUserFromChannel'
| 'createSecret'
| 'updateSecret'
| 'secretRecipientAdded'
| 'secretRecipientDeleted'
| 'deleteSecret'
| 'createNotification'
| 'seenNotification'
| 'followUser'
| 'unfollowUser'
| 'connect'
| 'disconnect'
| 'any';
type Id = {
id: number;
}
type IdUser = Id & {
user_id: number;
}
type UserIdChannelId = {
user_id: number;
channel_id: number;
}
export type WebSocketMessageData =
| Message
| Channel
| { id: number }
| Secret
| Notification
| Id
| IdUser
| null
| UserIdChannelId
| {
type: WebSocketMessageType;
};
type: WebSocketMessageType;
};
export type WebsoketMessage = {
type: WebSocketMessageType;
@@ -34,7 +64,7 @@ appWebsocket.on('any', (data) => {
console.log(`[WS] Recieved message: `, data);
});
function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
async function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
switch (type) {
case 'createMessage':
messagesCache.set((data as Message).id, data as Message);
@@ -46,7 +76,50 @@ function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
channelsCache.set((data as Channel).id, data as Channel);
break;
case 'deleteChannel':
channelsCache.remove((data as { id: number }).id);
channelsCache.remove((data as Id).id);
break;
case 'addedUserToChannel': {
const { user_id, channel_id } = data as UserIdChannelId;
const channelUsers = await channelsUserCache.get(channel_id) || new Set();
channelUsers.add(user_id);
channelsUserCache.set(channel_id, channelUsers);
}
break;
case 'removedUserFromChannel': {
const { user_id, channel_id } = data as UserIdChannelId;
const channelUsers = await channelsUserCache.get(channel_id) || new Set();
channelUsers.delete(user_id);
channelsUserCache.set(channel_id, channelUsers);
}
break;
case 'createSecret':
secretCache.set((data as Secret).id, data as Secret);
break;
case 'updateSecret':
secretCache.set((data as Secret).id, data as Secret);
break;
case 'deleteSecret':
secretCache.remove((data as Id).id);
break;
case 'createNotification':
notificationsCache.set((data as Notification).id, data as Notification);
break;
case 'seenNotification': {
const notification = await notificationsCache.get((data as Id).id);
if (notification) {
notificationsCache.set(notification.id, { ...notification, seen: true });
}
}
break;
case 'followUser':
followingUserCache.set((data as IdUser).user_id, true);
break;
case 'unfollowUser':
followingUserCache.set((data as IdUser).user_id, false);
break;
default:
break;
@@ -54,6 +127,8 @@ function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
}
const connect = (token: string) => {
if (!browser)
return null;
const websocket = new WebSocket(`ws://${BASE_API_URL}/ws/${token}`);
websocket.onopen = () => {

View File

@@ -6,6 +6,11 @@ export function isErrorResponse(data: unknown): data is ErrorResponse {
return (data as ErrorResponse).error !== undefined;
}
export function dataOrNull<T>(data: T | ErrorResponse): T | null {
if (isErrorResponse(data)) return null;
return data;
}
export type Token = {
token: string;
userId: number;
@@ -17,6 +22,7 @@ export type User = {
id: number;
username: string;
avatar?: string;
lastSeen: string;
createdAt: string;
};
@@ -26,6 +32,7 @@ export type Message = {
authorId: number;
content: string;
createdAt: string;
system: boolean;
};
export type Channel = {
@@ -34,3 +41,28 @@ export type Channel = {
lastMessageId?: number;
createdAt: string;
};
export type Secret = {
id: number;
userId: number;
name: string;
content: string;
timeoutSeconds: number;
expired: boolean;
createdAt: string;
};
export type ChannelUserPermissions = {
userId: number;
channelId: number;
admin: boolean;
};
export type Notification = {
id: number;
userId: number;
title: string;
body: string;
seen: boolean;
createdAt: string;
};

View File

@@ -3,6 +3,7 @@
import { token } from '$lib/stores/user';
import { usersCache } from '$lib/stores/cache';
import { appWebsocket } from '$lib/stores/websocket';
import { toast } from 'svelte-sonner';
export let data: LayoutData;
@@ -10,6 +11,16 @@
const user = data.user;
usersCache.set(user.id, user);
appWebsocket.on_autoDispose('createNotification', (data) => {
const typedNotification = data as unknown as Notification;
console.log('createNotification', typedNotification);
toast.info(typedNotification.body, {
position: 'bottom-right'
});
});
</script>
<slot />

View File

@@ -10,6 +10,7 @@
export let messages: MessageType[] = [];
let messageArea: MessageArea;
let scrollToBottom = false;
const sendMessage = (content: string) => {
if (!channel) return;
@@ -17,12 +18,19 @@
createMessage(channel.id, content);
};
export function updateMessages(newMessages: Message[]) {
export function updateMessages(newMessages: Message[], scrollToBottom: boolean = false) {
messages = newMessages;
scrollToBottom = scrollToBottom;
}
afterUpdate(() => {
if (!messageArea) return;
if (scrollToBottom) {
messageArea.scroll('bottom', 'instant');
scrollToBottom = false;
}
if (messageArea.getScrollPercent() > 0.95) messageArea.scroll('bottom', 'smooth');
});
</script>
@@ -41,7 +49,9 @@
<div class="flex h-screen w-[95%] flex-col contain-strict">
<div class="z-[10000] contents flex-grow">
<MessageArea {messages} bind:this={messageArea} />
{#key messages}
<MessageArea {messages} bind:this={messageArea} />
{/key}
</div>
<div class="relative bottom-0 left-0 right-0 m-4 mt-1 max-h-[40%] flex-grow">
<TextField onSend={sendMessage} />

View File

@@ -3,43 +3,53 @@
import { user } from '$lib/stores/user';
import { cn } from '$lib/utils';
import * as Avatar from '$lib/components/ui/avatar';
import { usersCache } from '$lib/stores/cache';
import { usersCache, messagesCache } from '$lib/stores/cache';
import { writable, type Writable } from 'svelte/store';
import { onDestroy, onMount } from 'svelte';
import type { ListenerDisposable } from '$lib/event';
import UserContext from '$lib/components/user-context.svelte';
export let message: Message;
let sender: Writable<User | null> = writable(null);
let sender = usersCache.get(message.authorId) as Promise<User>;
usersCache.get(message.authorId).then((user) => ($sender = user));
$: username = (isSelf ? $user?.username : $sender?.username) || 'N';
$: isSelf = $user?.id === message.authorId;
$: color = isSelf ? 'bg-accent' : 'bg-secondary';
$: position = isSelf ? 'justify-end' : 'justify-start';
$: timestampPosition = isSelf ? 'text-right' : 'text-left';
function updateMessage(cachedMessage: Message | null) {
message = cachedMessage || message;
}
messagesCache.subscribeKey_autoDispose(message.id, 'update', updateMessage);
</script>
<div class="w-full contain-inline-size">
<div class={cn('flex flex-row justify-between space-x-2', position)}>
{#if !isSelf}
<Avatar.Root class="h-16 w-16">
<Avatar.Fallback>{username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
{/if}
{#await sender then sender}
<div class="w-full contain-inline-size">
<div class={cn('flex flex-row justify-between space-x-2', position)}>
{#if !isSelf}
<UserContext user={sender}>
<Avatar.Root class="h-16 w-16">
<Avatar.Fallback>{sender.username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
</UserContext>
{/if}
<div class={cn('flex max-w-[60%] flex-col rounded-2xl px-4 py-2', color)}>
<span class="whitespace-pre-line break-words break-all text-left text-xl font-bold">
{message.content}
</span>
<span class={cn('text-sm', timestampPosition)}
>{new Date(message.createdAt).toLocaleString()}
</span>
<div class={cn('flex max-w-[60%] flex-col rounded-2xl px-4 py-2', color)}>
<span class="whitespace-pre-line break-words break-all text-left text-xl font-bold">
{message.content}
</span>
<span class={cn('text-sm', timestampPosition)}
>{new Date(message.createdAt).toLocaleString()}
</span>
</div>
{#if isSelf}
<Avatar.Root class="h-16 w-16">
<Avatar.Fallback>{sender.username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
{/if}
</div>
{#if isSelf}
<Avatar.Root class="h-16 w-16">
<Avatar.Fallback>{username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
{/if}
</div>
</div>
{/await}

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import ChannelContext from '$lib/components/channel-context.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { usersCache, messagesCache, channelsCache } from '$lib/stores/cache';
import { appWebsocket } from '$lib/stores/websocket';
import { type Channel, type Message, type User } from '$lib/types';
import { cn } from '$lib/utils';
export let channel: Channel;
let lastMessage: Message | null = null;
let lastMessageAuthor: User | null = null;
$: if (channel.lastMessageId) {
messagesCache.get(channel.lastMessageId).then((message) => (lastMessage = message));
}
$: if (lastMessage) {
usersCache.get(lastMessage.authorId).then((user) => (lastMessageAuthor = user));
}
export let selected: boolean = false;
export let onClick: () => void = () => {};
$: buttonColor = selected ? 'bg-accent' : 'hover:bg-secondary-foreground';
let notification = false;
function updateChannel(cachedChannel: Channel | null) {
channel = cachedChannel || channel;
if (!selected) notification = true;
}
function handleClick() {
notification = false;
onClick();
}
channelsCache.subscribeKey_autoDispose(channel.id, 'update', updateChannel);
appWebsocket.on_autoDispose('updateChannel', (data) => {
const typedChannel = data as Channel;
if (typedChannel.id === channel.id) {
updateChannel(typedChannel);
}
});
</script>
<ChannelContext {channel}>
<button
on:click={handleClick}
class={cn(
'flex w-full space-x-4 rounded-xl p-4 transition-colors contain-inline-size',
buttonColor
)}
>
<div>
<Avatar.Root class="h-12 w-12">
<Avatar.Image src="/default-avatar.png" />
<Avatar.Fallback>{channel.name[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
</div>
<div class="flex flex-col overflow-hidden">
<span class="overflow-hidden text-ellipsis text-left text-xl font-bold">
{channel.name}
</span>
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-left text-sm">
{#if lastMessage}
{`${lastMessageAuthor?.username}: ${lastMessage.content}`}
{/if}
</span>
</div>
{#if notification}
<div class="relative right-0 top-0 h-[14px] w-[14px] bg-blue-400"></div>
{/if}
</button>
</ChannelContext>

View File

@@ -17,6 +17,14 @@
export function deselect() {
selectedChannel.set(undefined);
}
export function getSelectedId() {
return $selectedChannel;
}
export function updateChannels(newChannels: Channel[]) {
channels = newChannels;
}
</script>
<div class="flex flex-col gap-2">

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import {
BookLock,
LogOut,
Menu,
Plus,
Settings,
UserPlus,
type Icon,
type IconProps
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
import { goto } from '$app/navigation';
import type { SuperValidated } from 'sveltekit-superforms';
import type { CreateChannelFormSchema } from '../../(forms)/create-channel/create-channel-form.svelte';
import CreateChannelDialog from '../../(forms)/create-channel/create-channel-dialog.svelte';
import UserSearch from '$lib/components/user-search.svelte';
import { followUser, searchUsersByUsername } from '$lib/api/user';
import { isErrorResponse, type User } from '$lib/types';
import { followingUserCache } from '$lib/stores/cache';
import { user as userStore } from '$lib/stores/user';
type MenuItem =
| {
name: string;
icon: ComponentType<Icon>;
iconProps?: IconProps;
onClick: () => void;
}
| {};
export let createChannelForm: SuperValidated<CreateChannelFormSchema>;
let createChannelDialogOpen = false;
let userSearchOpen = false;
const menuItems: MenuItem[] = [
{
name: 'Create Channel',
icon: Plus as ComponentType<Icon>,
onClick: () => {
createChannelDialogOpen = true;
}
},
{
name: 'Follow user',
icon: UserPlus as ComponentType<Icon>,
onClick: () => {
userSearchOpen = true;
}
},
{},
{
name: 'Settings',
icon: Settings as ComponentType<Icon>,
onClick: () => {
goto('/settings');
}
},
{
name: 'Secrets',
icon: BookLock as ComponentType<Icon>,
onClick: () => {
goto('/secrets');
}
},
{},
{
name: 'Logout',
icon: LogOut as ComponentType<Icon>,
onClick: () => {
goto('/logout');
}
}
];
async function getQueryUsers(query: string) {
if (!query) return [];
const users = await searchUsersByUsername(query);
if (isErrorResponse(users)) {
return [];
}
let searchUsers = [];
for (const user of users) {
const isFollowing = await followingUserCache.get(user.id);
if (!isFollowing && user.id !== $userStore?.id) {
searchUsers.push(user);
}
}
return searchUsers;
}
</script>
<CreateChannelDialog bind:open={createChannelDialogOpen} data={createChannelForm} />
<UserSearch
bind:open={userSearchOpen}
onSelect={(user) => followUser(user.id)}
onQueryUpdate={getQueryUsers}
/>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button builders={[builder]} variant="ghost" class="h-10 w-10 p-0">
<Menu />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>My Account</DropdownMenu.Label>
<Separator />
{#each menuItems as item}
{#if item && 'name' in item}
<DropdownMenu.Item on:click={item.onClick}
><div class="flex items-center gap-2 space-x-2">
<svelte:component this={item.icon} {...item.iconProps} />
{item.name}
</div></DropdownMenu.Item
>
{:else}
<DropdownMenu.Separator />
{/if}
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { user } from '$lib/stores/user';
import ThemeSwitch from '$lib/components/theme-switch.svelte';
import type { SuperValidated } from 'sveltekit-superforms';
import type { CreateChannelFormSchema } from '../../(forms)/create-channel/create-channel-form.svelte';
import ProfileDropdown from './profile-dropdown.svelte';
import { Bell } from 'lucide-svelte';
import Notifications from '$lib/components/notifications.svelte';
import { isErrorResponse, type Notification } from '$lib/types';
import { appWebsocket } from '$lib/stores/websocket';
import { getAllNotifications, seenNotification } from '$lib/api/notification';
export let createChannelForm: SuperValidated<CreateChannelFormSchema>;
let notifications: Notification[] = [];
getAllNotifications().then((data) => {
if (!isErrorResponse(data)) {
notifications = data;
}
});
appWebsocket.on_autoDispose('createNotification', (data) => {
const typedNotification = data as Notification;
notifications = [typedNotification, ...notifications];
});
appWebsocket.on_autoDispose('seenNotification', (data) => {
const typedId = data as { id: number };
notifications = notifications.map((notification) => {
if (notification.id === typedId.id) {
notification.seen = true;
}
return notification;
});
});
</script>
<div class="mx-4 my-3 flex">
<div>
<ProfileDropdown {createChannelForm} />
</div>
<div class="flex-grow pt-1 text-center text-2xl">
{$user?.username}
</div>
<div>
<Notifications
{notifications}
onSeenNotification={(notification) => seenNotification(notification.id)}
/>
</div>
<div>
<ThemeSwitch />
</div>
</div>

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import { usersCache, messagesCache } from '$lib/stores/cache';
import { type Channel, type Message, type User } from '$lib/types';
import { cn } from '$lib/utils';
export let channel: Channel;
let lastMessage: Message | null = null;
let lastMessageAuthor: User | null = null;
$: if (lastMessage) {
usersCache.get(lastMessage.authorId).then((user) => (lastMessageAuthor = user));
}
$: if (channel.lastMessageId) {
messagesCache.get(channel.lastMessageId).then((message) => (lastMessage = message));
}
export let selected: boolean = false;
export let onClick: () => void = () => {};
$: buttonColor = selected ? 'bg-accent' : 'hover:bg-secondary-foreground';
</script>
<button
on:click={onClick}
class={cn(
'flex w-full space-x-4 rounded-xl p-4 transition-colors contain-inline-size',
buttonColor
)}
>
<div>
<Avatar.Root class="h-12 w-12">
<Avatar.Image src="/default-avatar.png" />
<Avatar.Fallback>{channel.name[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
</div>
<div class="flex flex-col overflow-hidden">
<span class="overflow-hidden text-ellipsis text-left text-xl font-bold">
{channel.name}
</span>
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-left text-sm">
{#if lastMessage}
{`${lastMessageAuthor?.username}: ${lastMessage.content}`}
{/if}
</span>
</div>
</button>

View File

@@ -1,53 +0,0 @@
<script lang="ts" context="module">
import type { ComponentType } from 'svelte';
import type { Icon } from 'lucide-svelte';
import type { IconProps } from 'lucide-svelte';
export type MenuItem = {
name: string;
icon: ComponentType<Icon>;
iconProps?: IconProps;
onClick: () => void;
};
</script>
<script lang="ts">
import Menu from 'lucide-svelte/icons/menu';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { user } from '$lib/stores/user';
import ThemeSwitch from '$lib/components/theme-switch.svelte';
export let menuItems: MenuItem[] = [];
</script>
<div class="mx-4 my-3 flex">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button builders={[builder]} variant="ghost" class="h-10 w-10 p-0">
<Menu />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>My Account</DropdownMenu.Label>
<Separator />
{#each menuItems as item}
<DropdownMenu.Item on:click={item.onClick}
><div class="flex items-center gap-2 space-x-2">
<svelte:component this={item.icon} {...item.iconProps} />
{item.name}
</div></DropdownMenu.Item
>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<div class="flex-grow pt-1 text-center text-2xl">
{$user?.username}
</div>
<div>
<ThemeSwitch />
</div>
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import type { SuperValidated } from 'sveltekit-superforms';
import type { AddUserToChannelFormSchema } from './add-user-to-channel-form.svelte';
import AddUserToChannelForm from './add-user-to-channel-form.svelte';
export let data: SuperValidated<AddUserToChannelFormSchema>;
export let open: boolean = false;
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Channel</Dialog.Title>
<Dialog.Description>
<AddUserToChannelForm
{data}
onSuccess={(data) => {
if (data?.success) open = false;
}}
/>
</Dialog.Description>
</Dialog.Header>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,41 @@
<script lang="ts" context="module">
import { z } from 'zod';
export const addUserToChannelFormSchema = z.object({
name: z
.string()
.min(6, { message: 'Name must be at least 6 characters' })
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, {
message: 'Name can only contain letters, numbers, and underscores'
})
});
export type AddUserToChannelFormSchema = z.infer<typeof addUserToChannelFormSchema>;
</script>
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import * as Form from '$lib/components/ui/form';
import { superForm, type SuperValidated } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters';
export let data: SuperValidated<AddUserToChannelFormSchema>;
export let onSuccess: (data?: Record<string, unknown>) => void = () => {};
const form = superForm(data, {
validators: zodClient(addUserToChannelFormSchema),
onResult: ({ result }) => {
if (result.type === 'success') {
onSuccess(result.data);
}
}
});
const { form: formData, enhance } = form;
</script>
<div>
</div>

View File

@@ -0,0 +1,36 @@
import type { Actions, RequestEvent } from "@sveltejs/kit";
import { fail, superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { isErrorResponse } from "$lib/types";
import { createChannel } from "$lib/api/channel";
import { createChannelFormSchema } from "./create-channel-form.svelte";
export const actions: Actions = {
default: async (event) => {
const token = event.cookies.get('token');
const result = await processCreateChannelForm(event, token);
if (!result.valid) return fail(400, { createChannelForm: result.form });
return { createChannelForm: result.form, success: true };
}
};
async function processCreateChannelForm(event: RequestEvent<Partial<Record<string, string>>, string | null>, token: string | undefined) {
const createChannelForm = await superValidate(event, zod(createChannelFormSchema));
const result = { form: createChannelForm, valid: createChannelForm.valid };
console.log(createChannelForm.data);
if (!createChannelForm.valid) return result;
const response = await createChannel(createChannelForm.data.name, token);
if (isErrorResponse(response)) {
result.form.errors.name = [response.error];
return result;
}
return result;
}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import type { SuperValidated } from 'sveltekit-superforms';
import type { CreateChannelFormSchema } from './create-channel-form.svelte';
import CreateChannelForm from './create-channel-form.svelte';
export let data: SuperValidated<CreateChannelFormSchema>;
export let open: boolean = false;
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Channel</Dialog.Title>
<Dialog.Description>
<CreateChannelForm
{data}
onSuccess={(data) => {
if (data?.success) open = false;
}}
/>
</Dialog.Description>
</Dialog.Header>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,52 @@
<script lang="ts" context="module">
import { z } from 'zod';
export const createChannelFormSchema = z.object({
name: z
.string()
.min(6, { message: 'Name must be at least 6 characters' })
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, {
message: 'Name can only contain letters, numbers, and underscores'
})
});
export type CreateChannelFormSchema = z.infer<typeof createChannelFormSchema>;
</script>
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import * as Form from '$lib/components/ui/form';
import { superForm, type SuperValidated } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters';
export let data: SuperValidated<CreateChannelFormSchema>;
export let onSuccess: (data?: Record<string, unknown>) => void = () => {};
const form = superForm(data, {
validators: zodClient(createChannelFormSchema),
onResult: ({ result }) => {
if (result.type === 'success') {
onSuccess(result.data);
}
}
});
const { form: formData, enhance } = form;
</script>
<form method="post" action="/channels/create-channel" use:enhance class="flex flex-col">
<div>
<Form.Field name="name" {form}>
<Form.Control let:attrs>
<Form.Label>Name</Form.Label>
<Input {...attrs} bind:value={$formData.name} type="text" placeholder="name" />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<div class="flex flex-col space-y-2 pt-4">
<Form.Button type="submit">Create</Form.Button>
</div>
</div>
</form>

View File

@@ -0,0 +1,12 @@
import { superValidate } from 'sveltekit-superforms';
import type { LayoutServerLoad } from './$types';
import { zod } from 'sveltekit-superforms/adapters';
import { createChannelFormSchema } from './(forms)/create-channel/create-channel-form.svelte';
import { addUserToChannelFormSchema } from './(forms)/add-user-to-channel/add-user-to-channel-form.svelte';
export const load = (async () => {
return {
createChannelForm: await superValidate(zod(createChannelFormSchema)),
addUserToChannelForm: await superValidate(zod(addUserToChannelFormSchema))
};
}) satisfies LayoutServerLoad;

View File

@@ -2,17 +2,12 @@
import type { LayoutData } from './$types';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from './(components)/sidebar.svelte';
import SidebarHeader from './(components)/sidebar-header.svelte';
import { type Icon } from 'lucide-svelte';
import Settings from 'lucide-svelte/icons/settings';
import LogOut from 'lucide-svelte/icons/log-out';
import { onDestroy, onMount, type ComponentType } from 'svelte';
import type { MenuItem } from './(components)/sidebar-header.svelte';
import ChannelList from './(components)/channel-list.svelte';
import { appWebsocket, type WebSocketMessageType } from '$lib/stores/websocket';
import type { Channel } from '$lib/types';
import Sidebar from './(components)/(sidebar)/sidebar.svelte';
import SidebarHeader from './(components)/(sidebar)/sidebar-header.svelte';
import ChannelList from './(components)/(sidebar)/channel-list.svelte';
export let data: LayoutData;
@@ -21,29 +16,12 @@
let channelList: ChannelList | undefined;
function onKeyUp(event: KeyboardEvent) {
if (event.key === 'Escape') {
channelList?.deselect();
goto('/channels');
}
// if (event.key === 'Escape') {
// channelList?.deselect();
// goto('/channels');
// }
}
const menuItems: MenuItem[] = [
{
name: 'Settings',
icon: Settings as ComponentType<Icon>,
onClick: () => {
goto('/settings');
}
},
{
name: 'Logout',
icon: LogOut as ComponentType<Icon>,
onClick: () => {
goto('/logout');
}
}
];
$: channelId = parseInt($page.params.channel_id);
function handleChannelCreated(channel: unknown) {
@@ -52,19 +30,7 @@
if (!channelList) return;
channels.push(typedChannel);
}
function handleChannelUpdated(channel: unknown) {
const typedChannel = channel as Channel;
if (!channelList) return;
for (let i = 0; i < channels.length; i++) {
if (channels[i].id == typedChannel.id) {
channels[i] = typedChannel;
break;
}
}
channelList.updateChannels(channels);
}
function handleChannelDeleted(channel_id: unknown) {
@@ -73,23 +39,19 @@
if (!channelList) return;
channels = channels.filter((c) => c.id != id);
if (channelId == id) goto('/channels');
channelList.updateChannels(channels);
}
const handlers = {
createChannel: handleChannelCreated,
updateChannel: handleChannelUpdated,
deleteChannel: handleChannelDeleted
};
onMount(() => {
for (const [key, value] of Object.entries(handlers))
appWebsocket.on(key as WebSocketMessageType, value);
});
onDestroy(() => {
for (const [key, value] of Object.entries(handlers))
appWebsocket.off(key as WebSocketMessageType, value);
});
for (const [key, callback] of Object.entries(handlers))
appWebsocket.on_autoDispose(key as WebSocketMessageType, callback);
</script>
<svelte:window on:keyup={onKeyUp} />
@@ -98,7 +60,7 @@
<div class="hidden max-w-[370px] sm:w-[50%] md:inline-block">
<Sidebar>
<div slot="header">
<SidebarHeader {menuItems} />
<SidebarHeader createChannelForm={data.createChannelForm} />
</div>
<div slot="channels">

View File

@@ -4,12 +4,12 @@ import type { LayoutLoad } from './$types';
export const ssr = false;
export const load = (async ({ parent }) => {
export const load = (async ({ data, parent }) => {
await parent();
const response = await getAllChannels();
const channels = response as Channel[];
return { channels };
return { ...data, channels };
}) satisfies LayoutLoad;

View File

@@ -5,7 +5,6 @@
import { channelsCache } from '$lib/stores/cache';
import type { Channel, Message } from '$lib/types';
import { appWebsocket } from '$lib/stores/websocket';
import { onDestroy, onMount } from 'svelte';
export let data: PageData;
@@ -13,9 +12,10 @@
$: channelId = parseInt($page.params.channel_id);
$: channelsCache.get(channelId).then((c) => (channel = c));
const messages = data.messages;
$: messages = data.messages;
let channelArea: ChannelArea;
$: channelArea?.updateMessages(messages, true);
function handleCreateMessage(message: unknown) {
const typedMessage = message as Message;
@@ -27,13 +27,7 @@
channelArea?.updateMessages(messages);
}
onMount(() => {
appWebsocket.on('createMessage', handleCreateMessage);
});
onDestroy(() => {
appWebsocket.off('createMessage', handleCreateMessage);
});
appWebsocket.on_autoDispose('createMessage', handleCreateMessage);
</script>
<div class="flex h-screen w-full items-center justify-center">

View File

@@ -5,6 +5,7 @@ import type { PageLoad } from './$types';
import { isErrorResponse } from '$lib/types';
export const ssr = false;
export const prerender = false;
export const load = (async ({ params, parent }) => {
await parent();
@@ -17,5 +18,7 @@ export const load = (async ({ params, parent }) => {
if (isErrorResponse(messages)) return redirect(302, '/channels');
console.log(messages);
return { messages };
}) satisfies PageLoad;

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import type { Secret } from '$lib/types';
export let secret: Secret;
export let selected: boolean = false;
export let onClick: (secret: Secret) => void = () => {};
</script>
<div>
<Button
on:click={() => onClick(secret)}
variant={selected ? 'default' : 'outline'}
class="w-full"
>
{secret.name}
</Button>
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import type { Secret } from '$lib/types';
import { Plus } from 'lucide-svelte';
import SecretsListItem from './secrets-list-item.svelte';
import { Button } from '$lib/components/ui/button';
export let secrets: Secret[] = [];
$: selectedId = parseInt($page.params.secret_id);
export function select(secret: Secret) {
selectedId = secret.id;
goto(`/secrets/${secret.id}`);
}
</script>
<div class="flex w-full flex-col">
<ScrollArea class="flex h-full w-full flex-col ">
<div class="my-2">
<Button
href="/secrets/new"
variant="outline"
class="w-full"
on:click={() => (selectedId = -1)}
>
<Plus class="mr-2 h-4 w-4" />
New secret
</Button>
</div>
{#each secrets as secret}
<div class="my-2">
<SecretsListItem {secret} selected={selectedId === secret.id} onClick={select} />
</div>
{/each}
</ScrollArea>
</div>

View File

@@ -0,0 +1,114 @@
<script lang="ts" context="module">
import { z } from 'zod';
export const secretFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
content: z.string().min(1, 'Message is required'),
timeoutSeconds: z.number().int().nonnegative(),
recipients: z.array(z.number().int().nonnegative())
});
export type SecretFormSchema = z.infer<typeof secretFormSchema>;
</script>
<script lang="ts">
import * as Form from '$lib/components/ui/form';
import SuperDebug, { superForm, type SuperValidated } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters';
import { isErrorResponse, type Secret, type User } from '$lib/types';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import { afterUpdate, beforeUpdate, onMount } from 'svelte';
import { Textarea } from '$lib/components/ui/textarea';
import { getFollowedUsers } from '$lib/api/user';
export let data: SuperValidated<SecretFormSchema>;
export let defaultSecret: Secret | undefined = undefined;
export let followedUsers: User[] = [];
export let recipients: User[] = [];
export let reset: boolean = false;
const form = superForm(data, {
validators: zodClient(secretFormSchema),
resetForm: reset
});
const { form: formData, enhance } = form;
onMount(async () => {
formData.update((data) => {
if (defaultSecret) {
data.name = defaultSecret.name;
data.content = defaultSecret.content;
data.timeoutSeconds = defaultSecret.timeoutSeconds;
}
data.recipients = recipients.map((user) => user.id);
return data;
});
});
let seconds = defaultSecret?.timeoutSeconds.toString() || '60';
$: formData.update((data) => {
data.timeoutSeconds = parseInt(seconds);
return data;
});
$: formData.update((data) => {
data.recipients = recipients.map((user) => user.id);
return data;
});
</script>
<form method="post" use:enhance>
<Form.Field name="name" {form}>
<Form.Control let:attrs>
<Form.Label>Name</Form.Label>
<Input {...attrs} type="text" placeholder="Name" bind:value={$formData.name} />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Field name="content" {form}>
<Form.Control let:attrs>
<Form.Label>Message</Form.Label>
<Textarea {...attrs} placeholder="Message" bind:value={$formData.content} />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Field name="timeoutSeconds" {form}>
<Form.Control let:attrs>
<Form.Label>Timeout</Form.Label>
<Input {...attrs} type="number" placeholder="Timeout" bind:value={seconds} />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Fieldset {form} name="recipients">
<div class="flex flex-col gap-2">
{#each followedUsers as user}
{@const checked = $formData.recipients.includes(user.id)}
<Form.Control let:attrs>
{@const { name, ...rest } = attrs}
<div>
<Checkbox
{...rest}
{checked}
on:click={() => {
if (checked) {
$formData.recipients = $formData.recipients.filter(
(id) => id !== user.id
);
} else {
$formData.recipients = [...$formData.recipients, user.id];
}
}}
></Checkbox>
<input type="checkbox" {name} hidden value={user.id} {checked} />
<span>{user.username}</span>
</div>
</Form.Control>
{/each}
</div>
</Form.Fieldset>
<Form.Button>Save</Form.Button>
</form>

View File

@@ -0,0 +1,16 @@
import { getSecretsBySelf } from '$lib/api/secret';
import { isErrorResponse } from '$lib/types';
import type { LayoutServerLoad } from './$types';
export const ssr = false;
export const load = (async ({ cookies }) => {
const token = cookies.get('token');
if (!token) return { secrets: [] };
const secrets = await getSecretsBySelf(token);
if (isErrorResponse(secrets)) return { secrets: [] };
return { secrets };
}) satisfies LayoutServerLoad;

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { appWebsocket } from '$lib/stores/websocket';
import type { Secret } from '$lib/types';
import type { LayoutData } from './$types';
import SecretsList from './(components)/secrets-list.svelte';
export let data: LayoutData;
$: secrets = data.secrets;
appWebsocket.on_autoDispose('createSecret', (data) => {
const typedSecret = data as Secret;
secrets = [...secrets, typedSecret];
});
appWebsocket.on_autoDispose('deleteSecret', (data) => {
const id = (data as { id: number }).id;
secrets = secrets.filter((secret) => secret.id !== id);
});
appWebsocket.on_autoDispose('updateSecret', (data) => {
const typedSecret = data as Secret;
secrets = secrets.map((secret) => (secret.id === typedSecret.id ? typedSecret : secret));
});
</script>
<div class="flex h-full w-full flex-row">
<div class="h-full w-[20%] min-w-[200px] max-w-[400px] space-y-4 rounded-lg bg-secondary p-4">
<Button variant="outline" href="/channels">Back to channels</Button>
<SecretsList {secrets} />
</div>
<slot></slot>
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let secrets = data.secrets;
</script>

View File

@@ -0,0 +1,28 @@
import { superValidate } from 'sveltekit-superforms';
import type { PageServerLoad } from './$types';
import { secretFormSchema } from '../(forms)/secret-form.svelte';
import { zod } from 'sveltekit-superforms/adapters';
import { updateSecret } from '$lib/api/secret';
export const load = (async () => {
return { form: await superValidate(zod(secretFormSchema)) };
}) satisfies PageServerLoad;
export const actions = {
default: async (event) => {
const secretForm = await superValidate(event, zod(secretFormSchema));
if (!secretForm.valid) return { form: secretForm };
const { secret_id } = event.params;
const secretId = parseInt(secret_id);
const token = event.cookies.get('token');
console.log(secretForm.data);
await updateSecret(secretId, secretForm.data.name, secretForm.data.content, secretForm.data.timeoutSeconds, secretForm.data.recipients, token);
// if (isErrorResponse(response)) return response;
return { form: secretForm };
}
}

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { followingUserCache, usersCache } from '$lib/stores/cache';
import { appWebsocket } from '$lib/stores/websocket';
import type { Secret } from '$lib/types';
import type { PageData } from './$types';
import SecretForm from '../(forms)/secret-form.svelte';
export let data: PageData;
$: secret = data.secret;
$: recipients = data.recipients;
$: followedUsers = data.followedUsers;
appWebsocket.on_autoDispose('updateSecret', (data) => {
const typedSecret = data as Secret;
if (typedSecret.id === secret.id) {
secret = typedSecret;
}
});
appWebsocket.on_autoDispose('deleteSecret', (data) => {
const id = (data as { id: number }).id;
if (id === secret.id) {
goto('/secrets');
}
});
appWebsocket.on_autoDispose('secretRecipientAdded', async (data) => {
const typedRecipient = data as { id: number; user_id: number };
if (typedRecipient.id === secret.id) {
const user = await usersCache.get(typedRecipient.user_id);
if (user) recipients = [...recipients, user];
}
});
appWebsocket.on_autoDispose('secretRecipientDeleted', async (data) => {
const typedRecipient = data as { id: number; user_id: number };
if (typedRecipient.id === secret.id) {
recipients = recipients.filter((user) => user.id !== typedRecipient.user_id);
}
});
appWebsocket.on_autoDispose('followUser', async (data) => {
const typedFollow = data as { user_id: number };
const user = await usersCache.get(typedFollow.user_id);
if (user) followedUsers = [...followedUsers, user];
});
appWebsocket.on_autoDispose('unfollowUser', async (data) => {
const typedFollow = data as { user_id: number };
followedUsers = followedUsers.filter((user) => user.id !== typedFollow.user_id);
});
</script>
{#key secret}
<SecretForm defaultSecret={secret} data={data.form} {followedUsers} {recipients} />
{/key}

View File

@@ -0,0 +1,26 @@
import { getSecretById, getSecretRecipients } from '$lib/api/secret';
import { isErrorResponse } from '$lib/types';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { getFollowedUsers } from '$lib/api/user';
export const ssr = false;
export const load = (async ({ data, params, parent }) => {
const parentData = await parent();
const token = parentData.token;
const secretId = parseInt(params.secret_id);
const secret = await getSecretById(secretId, token);
if (isErrorResponse(secret)) return redirect(302, '/secrets');
const recipients = await getSecretRecipients(secretId, token);
if (isErrorResponse(recipients)) return redirect(302, '/secrets');
console.log("recipients", recipients);
const followedUsers = await getFollowedUsers(token);
if (isErrorResponse(followedUsers)) return redirect(302, '/secrets');
return { secret, recipients, followedUsers, ...data };
}) satisfies PageLoad;

View File

@@ -0,0 +1,28 @@
import { superValidate } from 'sveltekit-superforms';
import type { PageServerLoad } from './$types';
import { zod } from 'sveltekit-superforms/adapters';
import { createSecret } from '$lib/api/secret';
import { isErrorResponse } from '$lib/types';
import { redirect } from '@sveltejs/kit';
import { secretFormSchema } from '../(forms)/secret-form.svelte';
export const load = (async () => {
return { form: await superValidate(zod(secretFormSchema)) };
}) satisfies PageServerLoad;
export const actions = {
default: async (event) => {
const secretForm = await superValidate(event, zod(secretFormSchema));
if (!secretForm.valid) return { form: secretForm };
const token = event.cookies.get('token');
console.log(secretForm.data);
const response = await createSecret(secretForm.data.name, secretForm.data.content, secretForm.data.timeoutSeconds, secretForm.data.recipients, token);
if (isErrorResponse(response)) return redirect(302, '/secrets');
return redirect(302, `/secrets/${response.id}`);
}
}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { followingUserCache, usersCache } from '$lib/stores/cache';
import { appWebsocket } from '$lib/stores/websocket';
import type { Secret } from '$lib/types';
import SecretForm from '../(forms)/secret-form.svelte';
import type { PageData } from './$types';
export let data: PageData;
$: followedUsers = data.followedUsers;
appWebsocket.on_autoDispose('followUser', async (data) => {
const typedFollow = data as { user_id: number };
const user = await usersCache.get(typedFollow.user_id);
if (user) followedUsers = [...followedUsers, user];
});
appWebsocket.on_autoDispose('unfollowUser', async (data) => {
const typedFollow = data as { user_id: number };
followedUsers = followedUsers.filter((user) => user.id !== typedFollow.user_id);
});
</script>
<SecretForm reset data={data.form} {followedUsers} />

View File

@@ -0,0 +1,16 @@
import { isErrorResponse } from '$lib/types';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { getFollowedUsers } from '$lib/api/user';
export const ssr = false;
export const load = (async ({ data, parent }) => {
const parentData = await parent();
const token = parentData.token;
const followedUsers = await getFollowedUsers(token);
if (isErrorResponse(followedUsers)) return redirect(302, '/secrets');
return { followedUsers, ...data };
}) satisfies PageLoad;

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import type { LayoutData } from './$types';
export let data: LayoutData;
</script>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>

View File

@@ -3,11 +3,16 @@ import { zod } from 'sveltekit-superforms/adapters';
import { loginFormSchema } from './login-form.svelte';
import { superValidate } from 'sveltekit-superforms';
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { loginUser } from '$lib/api/user';
import { getByToken, loginUser } from '$lib/api/user';
import { isErrorResponse } from '$lib/types';
export const load = (async ({ cookies }) => {
if (cookies.get('token')) throw redirect(302, '/channels');
if (cookies.get('token')) {
const user = await getByToken(cookies.get('token'));
if (!isErrorResponse(user)) return redirect(302, '/channels');
}
cookies.delete('token', { path: '/' });
return { form: await superValidate(zod(loginFormSchema)) };
}) satisfies PageServerLoad;

View File

@@ -2,8 +2,10 @@
import '../app.css';
import { theme } from '$lib/stores/theme';
import { Toaster } from '$lib/components/ui/sonner';
</script>
<Toaster theme={$theme} />
<div class="h-full w-full">
<slot></slot>
</div>