semifinished
This commit is contained in:
@@ -36,8 +36,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"bits-ui": "^0.21.7",
|
"bits-ui": "^0.21.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk-sv": "^0.0.17",
|
||||||
"formsnap": "^1.0.0",
|
"formsnap": "^1.0.0",
|
||||||
"lucide-svelte": "^0.378.0",
|
"lucide-svelte": "^0.378.0",
|
||||||
"mode-watcher": "^0.3.0",
|
"mode-watcher": "^0.3.0",
|
||||||
|
|||||||
@@ -1,46 +1,55 @@
|
|||||||
import { messagesCache, channelsCache } from '$lib/stores/cache';
|
import { messagesCache, channelsCache, channelsUserCache } from '$lib/stores/cache';
|
||||||
import type { Channel, Message } from '../types';
|
import type { Channel, ChannelUserPermissions, Message } from '../types';
|
||||||
import { apiRequest } from './utils';
|
import { apiRequest } from './utils';
|
||||||
|
|
||||||
export async function getAllChannels() {
|
export async function getAllChannels(token?: string) {
|
||||||
return await apiRequest<Channel[]>('/channel', 'get', undefined, (data) => {
|
return await apiRequest<Channel[]>('/channel', 'get', { token }, (data) => {
|
||||||
data.forEach((channel) => {
|
data.forEach((channel) => {
|
||||||
channelsCache.set(channel.id, channel);
|
channelsCache.set(channel.id, channel);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChannelById(channelId: number) {
|
export async function getChannelById(channelId: number, token?: string) {
|
||||||
return await apiRequest<Channel>(`/channel/${channelId}`, 'get', undefined, (data) => {
|
return await apiRequest<Channel>(`/channel/${channelId}`, 'get', { token }, (data) => {
|
||||||
channelsCache.set(data.id, data);
|
channelsCache.set(data.id, data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createChannel(name: string) {
|
export async function createChannel(name: string, token?: string) {
|
||||||
return await apiRequest<Channel>('/channel', 'post', { data: { name } }, (data) => {
|
return await apiRequest<Channel>('/channel', 'post', { data: { name }, token }, (data) => {
|
||||||
channelsCache.set(data.id, data);
|
channelsCache.set(data.id, data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteChannel(channelId: number) {
|
export async function deleteChannel(channelId: number, token?: string) {
|
||||||
return await apiRequest<Channel>(`/channel/${channelId}`, 'delete', undefined, (data) => {
|
return await apiRequest<Channel>(`/channel/${channelId}`, 'delete', { token }, (data) => {
|
||||||
channelsCache.remove(data.id);
|
channelsCache.remove(data.id);
|
||||||
|
channelsUserCache.remove(data.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addUserToChannel(channelId: number, userId: number) {
|
export async function addUserToChannel(channelId: number, userId: number, token?: string) {
|
||||||
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'post');
|
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) {
|
export async function removeUserFromChannel(channelId: number, userId: number, token?: string) {
|
||||||
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'delete');
|
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[]>(
|
return await apiRequest<Message[]>(
|
||||||
`/channel/${channelId}/message${beforeId || limit ? '?' : ''}${beforeId ? `before=${beforeId}` : ''}${limit ? `&limit=${limit}` : ''}`,
|
`/channel/${channelId}/message`,
|
||||||
'get',
|
'get',
|
||||||
undefined,
|
{ params: { before: beforeId, limit }, token },
|
||||||
(data) => {
|
(data) => {
|
||||||
data.forEach((message) => {
|
data.forEach((message) => {
|
||||||
messagesCache.set(message.id, 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 });
|
||||||
|
}
|
||||||
|
|||||||
26
src/lib/api/notification.ts
Normal file
26
src/lib/api/notification.ts
Normal 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
52
src/lib/api/secret.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 type { Token, User } from '../types';
|
||||||
import { apiRequest } from './utils';
|
import { apiRequest } from './utils';
|
||||||
|
|
||||||
@@ -32,3 +32,47 @@ export async function loginUser(username: string, password: string) {
|
|||||||
export async function registerUser(username: string, password: string) {
|
export async function registerUser(username: string, password: string) {
|
||||||
return await apiRequest<Token>('/user/register', 'post', { data: { username, password } });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function apiRequest<T>(
|
|||||||
): Promise<T | ErrorResponse> {
|
): Promise<T | ErrorResponse> {
|
||||||
const url = API_URL + path;
|
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();
|
const token = options?.token || getUserToken();
|
||||||
|
|
||||||
|
|||||||
171
src/lib/components/channel-context.svelte
Normal file
171
src/lib/components/channel-context.svelte
Normal 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>
|
||||||
65
src/lib/components/notifications.svelte
Normal file
65
src/lib/components/notifications.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
14
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
14
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal 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>
|
||||||
40
src/lib/components/ui/alert-dialog/index.ts
Normal file
40
src/lib/components/ui/alert-dialog/index.ts
Normal 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,
|
||||||
|
};
|
||||||
35
src/lib/components/ui/checkbox/checkbox.svelte
Normal file
35
src/lib/components/ui/checkbox/checkbox.svelte
Normal 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>
|
||||||
6
src/lib/components/ui/checkbox/index.ts
Normal file
6
src/lib/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Root from "./checkbox.svelte";
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Checkbox,
|
||||||
|
};
|
||||||
23
src/lib/components/ui/command/command-dialog.svelte
Normal file
23
src/lib/components/ui/command/command-dialog.svelte
Normal 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>
|
||||||
12
src/lib/components/ui/command/command-empty.svelte
Normal file
12
src/lib/components/ui/command/command-empty.svelte
Normal 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>
|
||||||
18
src/lib/components/ui/command/command-group.svelte
Normal file
18
src/lib/components/ui/command/command-group.svelte
Normal 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>
|
||||||
23
src/lib/components/ui/command/command-input.svelte
Normal file
23
src/lib/components/ui/command/command-input.svelte
Normal 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>
|
||||||
24
src/lib/components/ui/command/command-item.svelte
Normal file
24
src/lib/components/ui/command/command-item.svelte
Normal 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>
|
||||||
15
src/lib/components/ui/command/command-list.svelte
Normal file
15
src/lib/components/ui/command/command-list.svelte
Normal 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>
|
||||||
10
src/lib/components/ui/command/command-separator.svelte
Normal file
10
src/lib/components/ui/command/command-separator.svelte
Normal 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} />
|
||||||
16
src/lib/components/ui/command/command-shortcut.svelte
Normal file
16
src/lib/components/ui/command/command-shortcut.svelte
Normal 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>
|
||||||
22
src/lib/components/ui/command/command.svelte
Normal file
22
src/lib/components/ui/command/command.svelte
Normal 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>
|
||||||
37
src/lib/components/ui/command/index.ts
Normal file
37
src/lib/components/ui/command/index.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
31
src/lib/components/ui/context-menu/context-menu-item.svelte
Normal file
31
src/lib/components/ui/context-menu/context-menu-item.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = ContextMenuPrimitive.ItemProps & {
|
||||||
|
inset?: boolean;
|
||||||
|
};
|
||||||
|
type $$Events = ContextMenuPrimitive.ItemEvents;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let inset: $$Props["inset"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
class={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
on:click
|
||||||
|
on:keydown
|
||||||
|
on:focusin
|
||||||
|
on:focusout
|
||||||
|
on:pointerdown
|
||||||
|
on:pointerleave
|
||||||
|
on:pointermove
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuPrimitive.Item>
|
||||||
19
src/lib/components/ui/context-menu/context-menu-label.svelte
Normal file
19
src/lib/components/ui/context-menu/context-menu-label.svelte
Normal 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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
type $$Props = ContextMenuPrimitive.RadioGroupProps;
|
||||||
|
|
||||||
|
export let value: $$Props["value"] = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenuPrimitive.RadioGroup {...$$restProps} bind:value>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuPrimitive.RadioGroup>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||||
|
import 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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
49
src/lib/components/ui/context-menu/index.ts
Normal file
49
src/lib/components/ui/context-menu/index.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Item from "./context-menu-item.svelte";
|
||||||
|
import Label from "./context-menu-label.svelte";
|
||||||
|
import Content from "./context-menu-content.svelte";
|
||||||
|
import Shortcut from "./context-menu-shortcut.svelte";
|
||||||
|
import RadioItem from "./context-menu-radio-item.svelte";
|
||||||
|
import Separator from "./context-menu-separator.svelte";
|
||||||
|
import RadioGroup from "./context-menu-radio-group.svelte";
|
||||||
|
import SubContent from "./context-menu-sub-content.svelte";
|
||||||
|
import SubTrigger from "./context-menu-sub-trigger.svelte";
|
||||||
|
import CheckboxItem from "./context-menu-checkbox-item.svelte";
|
||||||
|
|
||||||
|
const Sub = ContextMenuPrimitive.Sub;
|
||||||
|
const Root = ContextMenuPrimitive.Root;
|
||||||
|
const Trigger = ContextMenuPrimitive.Trigger;
|
||||||
|
const Group = ContextMenuPrimitive.Group;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sub,
|
||||||
|
Root,
|
||||||
|
Item,
|
||||||
|
Label,
|
||||||
|
Group,
|
||||||
|
Trigger,
|
||||||
|
Content,
|
||||||
|
Shortcut,
|
||||||
|
Separator,
|
||||||
|
RadioItem,
|
||||||
|
SubContent,
|
||||||
|
SubTrigger,
|
||||||
|
RadioGroup,
|
||||||
|
CheckboxItem,
|
||||||
|
//
|
||||||
|
Root as ContextMenu,
|
||||||
|
Sub as ContextMenuSub,
|
||||||
|
Item as ContextMenuItem,
|
||||||
|
Label as ContextMenuLabel,
|
||||||
|
Group as ContextMenuGroup,
|
||||||
|
Content as ContextMenuContent,
|
||||||
|
Trigger as ContextMenuTrigger,
|
||||||
|
Shortcut as ContextMenuShortcut,
|
||||||
|
RadioItem as ContextMenuRadioItem,
|
||||||
|
Separator as ContextMenuSeparator,
|
||||||
|
RadioGroup as ContextMenuRadioGroup,
|
||||||
|
SubContent as ContextMenuSubContent,
|
||||||
|
SubTrigger as ContextMenuSubTrigger,
|
||||||
|
CheckboxItem as ContextMenuCheckboxItem,
|
||||||
|
};
|
||||||
36
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
36
src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||||
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal 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>
|
||||||
16
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||||
13
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
13
src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||||
21
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
21
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||||
|
/>
|
||||||
8
src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
8
src/lib/components/ui/dialog/dialog-portal.svelte
Normal 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>
|
||||||
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal 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>
|
||||||
37
src/lib/components/ui/dialog/index.ts
Normal file
37
src/lib/components/ui/dialog/index.ts
Normal 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,
|
||||||
|
};
|
||||||
68
src/lib/components/user-context.svelte
Normal file
68
src/lib/components/user-context.svelte
Normal 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>
|
||||||
68
src/lib/components/user-search.svelte
Normal file
68
src/lib/components/user-search.svelte
Normal 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>
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
|
||||||
export interface Listener<D> {
|
export interface Listener<D> {
|
||||||
(event: D): unknown;
|
(event: D): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Disposable {
|
export interface ListenerDisposable {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EventEmitter<E, D> {
|
export class EventEmitter<E, D> {
|
||||||
private listeners: Map<E, Listener<D>[]> = new Map();
|
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, []);
|
if (!this.listeners.has(event)) this.listeners.set(event, []);
|
||||||
|
|
||||||
this.listeners.get(event)?.push(listener);
|
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>) => {
|
off = (event: E, listener: Listener<D>) => {
|
||||||
if (!this.listeners.has(event)) return;
|
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));
|
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));
|
return this.on(event, (e) => te.emit(event, e));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/lib/stores/cache/index.ts
vendored
48
src/lib/stores/cache/index.ts
vendored
@@ -1,23 +1,49 @@
|
|||||||
import { getChannelById } from '$lib/api/channel';
|
import { getChannelById } from '$lib/api/channel';
|
||||||
import { getMessageById } from '$lib/api/message';
|
import { getMessageById } from '$lib/api/message';
|
||||||
import { getUserById } from '$lib/api/user';
|
import { getNotificationById } from '$lib/api/notification';
|
||||||
import { isErrorResponse, type Channel, type Message, type User } from '$lib/types';
|
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';
|
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);
|
const response = await getUserById(id);
|
||||||
if (isErrorResponse(response)) return null;
|
return dataOrNull(response);
|
||||||
return 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);
|
const response = await getMessageById(id);
|
||||||
if (isErrorResponse(response)) return null;
|
return dataOrNull(response);
|
||||||
return 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);
|
const response = await getChannelById(id);
|
||||||
if (isErrorResponse(response)) return null;
|
return dataOrNull(response);
|
||||||
return 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;
|
||||||
});
|
});
|
||||||
|
|||||||
53
src/lib/stores/cache/utils.ts
vendored
53
src/lib/stores/cache/utils.ts
vendored
@@ -1,19 +1,26 @@
|
|||||||
|
import { EventEmitter } from '$lib/event';
|
||||||
import { get, writable, type Writable } from 'svelte/store';
|
import { get, writable, type Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export type CacheEvent = 'add' | 'update' | 'remove';
|
||||||
|
|
||||||
export class Cache<I, T> {
|
export class Cache<I, T> {
|
||||||
private data: Writable<Map<I, T>> = writable(new Map<I, T>());
|
private data: Writable<Map<I, T>> = writable(new Map<I, T>());
|
||||||
private runningCaches: Set<I> = new Set();
|
private runningCaches: Set<I> = new Set();
|
||||||
|
private eventEmitter = new EventEmitter<CacheEvent, [I, T | null]>();
|
||||||
|
|
||||||
|
private name: string;
|
||||||
|
|
||||||
private resolver: (data: I) => Promise<T | null>;
|
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;
|
this.resolver = resolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(key: I): Promise<T | null> {
|
async get(key: I): Promise<T | null> {
|
||||||
const cached = get(this.data).get(key);
|
const cached = get(this.data).get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`[Cache] Found in cache: `, cached);
|
console.log(`[Cache] Found in cache ${key}/${this.name}: `, cached);
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,24 +41,56 @@ export class Cache<I, T> {
|
|||||||
|
|
||||||
this.runningCaches.delete(key);
|
this.runningCaches.delete(key);
|
||||||
|
|
||||||
if (data)
|
|
||||||
console.log(`[Cache] Added to cache: `, data);
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(key: I, value: T) {
|
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));
|
this.data.update((data) => data.set(key, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(key: I) {
|
remove(key: I) {
|
||||||
console.log(`[Cache] Removed from cache: `, key);
|
console.log(`[Cache] Removed from cache ${key}/${this.name}: `, key);
|
||||||
|
|
||||||
this.data.update((data) => {
|
this.data.update((data) => {
|
||||||
data.delete(key);
|
data.delete(key);
|
||||||
return data;
|
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]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,53 @@ import { BASE_API_URL } from '$lib/constants';
|
|||||||
import { EventEmitter } from '$lib/event';
|
import { EventEmitter } from '$lib/event';
|
||||||
import { derived, get } from 'svelte/store';
|
import { derived, get } from 'svelte/store';
|
||||||
import { token as tokenStore } from './user';
|
import { token as tokenStore } from './user';
|
||||||
import type { Channel, Message } from '$lib/types';
|
import type { Channel, Message, Secret, Notification } from '$lib/types';
|
||||||
import { messagesCache, channelsCache } from './cache';
|
import { messagesCache, channelsCache, channelsUserCache, secretCache, notificationsCache, followingUserCache } from './cache';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
export type WebSocketMessageType =
|
export type WebSocketMessageType =
|
||||||
| 'createMessage'
|
| 'createMessage'
|
||||||
| 'updateChannel'
|
| 'updateChannel'
|
||||||
| 'createChannel'
|
| 'createChannel'
|
||||||
| 'deleteChannel'
|
| 'deleteChannel'
|
||||||
|
| 'addedUserToChannel'
|
||||||
|
| 'removedUserFromChannel'
|
||||||
|
| 'createSecret'
|
||||||
|
| 'updateSecret'
|
||||||
|
| 'secretRecipientAdded'
|
||||||
|
| 'secretRecipientDeleted'
|
||||||
|
| 'deleteSecret'
|
||||||
|
| 'createNotification'
|
||||||
|
| 'seenNotification'
|
||||||
|
| 'followUser'
|
||||||
|
| 'unfollowUser'
|
||||||
|
|
||||||
| 'connect'
|
| 'connect'
|
||||||
| 'disconnect'
|
| 'disconnect'
|
||||||
| 'any';
|
| 'any';
|
||||||
|
|
||||||
|
type Id = {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdUser = Id & {
|
||||||
|
user_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserIdChannelId = {
|
||||||
|
user_id: number;
|
||||||
|
channel_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type WebSocketMessageData =
|
export type WebSocketMessageData =
|
||||||
| Message
|
| Message
|
||||||
| Channel
|
| Channel
|
||||||
| { id: number }
|
| Secret
|
||||||
|
| Notification
|
||||||
|
| Id
|
||||||
|
| IdUser
|
||||||
| null
|
| null
|
||||||
|
| UserIdChannelId
|
||||||
| {
|
| {
|
||||||
type: WebSocketMessageType;
|
type: WebSocketMessageType;
|
||||||
};
|
};
|
||||||
@@ -34,7 +64,7 @@ appWebsocket.on('any', (data) => {
|
|||||||
console.log(`[WS] Recieved message: `, data);
|
console.log(`[WS] Recieved message: `, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
|
async function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'createMessage':
|
case 'createMessage':
|
||||||
messagesCache.set((data as Message).id, data as Message);
|
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);
|
channelsCache.set((data as Channel).id, data as Channel);
|
||||||
break;
|
break;
|
||||||
case 'deleteChannel':
|
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;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -54,6 +127,8 @@ function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connect = (token: string) => {
|
const connect = (token: string) => {
|
||||||
|
if (!browser)
|
||||||
|
return null;
|
||||||
const websocket = new WebSocket(`ws://${BASE_API_URL}/ws/${token}`);
|
const websocket = new WebSocket(`ws://${BASE_API_URL}/ws/${token}`);
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export function isErrorResponse(data: unknown): data is ErrorResponse {
|
|||||||
return (data as ErrorResponse).error !== undefined;
|
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 = {
|
export type Token = {
|
||||||
token: string;
|
token: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -17,6 +22,7 @@ export type User = {
|
|||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
lastSeen: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ export type Message = {
|
|||||||
authorId: number;
|
authorId: number;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
system: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Channel = {
|
export type Channel = {
|
||||||
@@ -34,3 +41,28 @@ export type Channel = {
|
|||||||
lastMessageId?: number;
|
lastMessageId?: number;
|
||||||
createdAt: string;
|
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;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { token } from '$lib/stores/user';
|
import { token } from '$lib/stores/user';
|
||||||
import { usersCache } from '$lib/stores/cache';
|
import { usersCache } from '$lib/stores/cache';
|
||||||
import { appWebsocket } from '$lib/stores/websocket';
|
import { appWebsocket } from '$lib/stores/websocket';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
export let data: LayoutData;
|
export let data: LayoutData;
|
||||||
|
|
||||||
@@ -10,6 +11,16 @@
|
|||||||
|
|
||||||
const user = data.user;
|
const user = data.user;
|
||||||
usersCache.set(user.id, 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>
|
</script>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
export let messages: MessageType[] = [];
|
export let messages: MessageType[] = [];
|
||||||
|
|
||||||
let messageArea: MessageArea;
|
let messageArea: MessageArea;
|
||||||
|
let scrollToBottom = false;
|
||||||
|
|
||||||
const sendMessage = (content: string) => {
|
const sendMessage = (content: string) => {
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
@@ -17,12 +18,19 @@
|
|||||||
createMessage(channel.id, content);
|
createMessage(channel.id, content);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function updateMessages(newMessages: Message[]) {
|
export function updateMessages(newMessages: Message[], scrollToBottom: boolean = false) {
|
||||||
messages = newMessages;
|
messages = newMessages;
|
||||||
|
|
||||||
|
scrollToBottom = scrollToBottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterUpdate(() => {
|
afterUpdate(() => {
|
||||||
if (!messageArea) return;
|
if (!messageArea) return;
|
||||||
|
if (scrollToBottom) {
|
||||||
|
messageArea.scroll('bottom', 'instant');
|
||||||
|
scrollToBottom = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (messageArea.getScrollPercent() > 0.95) messageArea.scroll('bottom', 'smooth');
|
if (messageArea.getScrollPercent() > 0.95) messageArea.scroll('bottom', 'smooth');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -41,7 +49,9 @@
|
|||||||
|
|
||||||
<div class="flex h-screen w-[95%] flex-col contain-strict">
|
<div class="flex h-screen w-[95%] flex-col contain-strict">
|
||||||
<div class="z-[10000] contents flex-grow">
|
<div class="z-[10000] contents flex-grow">
|
||||||
|
{#key messages}
|
||||||
<MessageArea {messages} bind:this={messageArea} />
|
<MessageArea {messages} bind:this={messageArea} />
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
<div class="relative bottom-0 left-0 right-0 m-4 mt-1 max-h-[40%] flex-grow">
|
<div class="relative bottom-0 left-0 right-0 m-4 mt-1 max-h-[40%] flex-grow">
|
||||||
<TextField onSend={sendMessage} />
|
<TextField onSend={sendMessage} />
|
||||||
|
|||||||
@@ -3,29 +3,38 @@
|
|||||||
import { user } from '$lib/stores/user';
|
import { user } from '$lib/stores/user';
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
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 { 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;
|
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;
|
$: isSelf = $user?.id === message.authorId;
|
||||||
|
|
||||||
$: color = isSelf ? 'bg-accent' : 'bg-secondary';
|
$: color = isSelf ? 'bg-accent' : 'bg-secondary';
|
||||||
$: position = isSelf ? 'justify-end' : 'justify-start';
|
$: position = isSelf ? 'justify-end' : 'justify-start';
|
||||||
$: timestampPosition = isSelf ? 'text-right' : 'text-left';
|
$: timestampPosition = isSelf ? 'text-right' : 'text-left';
|
||||||
|
|
||||||
|
function updateMessage(cachedMessage: Message | null) {
|
||||||
|
message = cachedMessage || message;
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesCache.subscribeKey_autoDispose(message.id, 'update', updateMessage);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full contain-inline-size">
|
{#await sender then sender}
|
||||||
|
<div class="w-full contain-inline-size">
|
||||||
<div class={cn('flex flex-row justify-between space-x-2', position)}>
|
<div class={cn('flex flex-row justify-between space-x-2', position)}>
|
||||||
{#if !isSelf}
|
{#if !isSelf}
|
||||||
|
<UserContext user={sender}>
|
||||||
<Avatar.Root class="h-16 w-16">
|
<Avatar.Root class="h-16 w-16">
|
||||||
<Avatar.Fallback>{username[0].toUpperCase()}</Avatar.Fallback>
|
<Avatar.Fallback>{sender.username[0].toUpperCase()}</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
|
</UserContext>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class={cn('flex max-w-[60%] flex-col rounded-2xl px-4 py-2', color)}>
|
<div class={cn('flex max-w-[60%] flex-col rounded-2xl px-4 py-2', color)}>
|
||||||
@@ -38,8 +47,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if isSelf}
|
{#if isSelf}
|
||||||
<Avatar.Root class="h-16 w-16">
|
<Avatar.Root class="h-16 w-16">
|
||||||
<Avatar.Fallback>{username[0].toUpperCase()}</Avatar.Fallback>
|
<Avatar.Fallback>{sender.username[0].toUpperCase()}</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/await}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -17,6 +17,14 @@
|
|||||||
export function deselect() {
|
export function deselect() {
|
||||||
selectedChannel.set(undefined);
|
selectedChannel.set(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSelectedId() {
|
||||||
|
return $selectedChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateChannels(newChannels: Channel[]) {
|
||||||
|
channels = newChannels;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
12
src/routes/(auth)/channels/+layout.server.ts
Normal file
12
src/routes/(auth)/channels/+layout.server.ts
Normal 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;
|
||||||
@@ -2,17 +2,12 @@
|
|||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
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 { appWebsocket, type WebSocketMessageType } from '$lib/stores/websocket';
|
||||||
import type { Channel } from '$lib/types';
|
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;
|
export let data: LayoutData;
|
||||||
|
|
||||||
@@ -21,28 +16,11 @@
|
|||||||
let channelList: ChannelList | undefined;
|
let channelList: ChannelList | undefined;
|
||||||
|
|
||||||
function onKeyUp(event: KeyboardEvent) {
|
function onKeyUp(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
// if (event.key === 'Escape') {
|
||||||
channelList?.deselect();
|
// channelList?.deselect();
|
||||||
goto('/channels');
|
// 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);
|
$: channelId = parseInt($page.params.channel_id);
|
||||||
|
|
||||||
@@ -52,19 +30,7 @@
|
|||||||
if (!channelList) return;
|
if (!channelList) return;
|
||||||
|
|
||||||
channels.push(typedChannel);
|
channels.push(typedChannel);
|
||||||
}
|
channelList.updateChannels(channels);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChannelDeleted(channel_id: unknown) {
|
function handleChannelDeleted(channel_id: unknown) {
|
||||||
@@ -73,23 +39,19 @@
|
|||||||
if (!channelList) return;
|
if (!channelList) return;
|
||||||
|
|
||||||
channels = channels.filter((c) => c.id != id);
|
channels = channels.filter((c) => c.id != id);
|
||||||
|
|
||||||
|
if (channelId == id) goto('/channels');
|
||||||
|
|
||||||
|
channelList.updateChannels(channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
createChannel: handleChannelCreated,
|
createChannel: handleChannelCreated,
|
||||||
updateChannel: handleChannelUpdated,
|
|
||||||
deleteChannel: handleChannelDeleted
|
deleteChannel: handleChannelDeleted
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
for (const [key, callback] of Object.entries(handlers))
|
||||||
for (const [key, value] of Object.entries(handlers))
|
appWebsocket.on_autoDispose(key as WebSocketMessageType, callback);
|
||||||
appWebsocket.on(key as WebSocketMessageType, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
for (const [key, value] of Object.entries(handlers))
|
|
||||||
appWebsocket.off(key as WebSocketMessageType, value);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keyup={onKeyUp} />
|
<svelte:window on:keyup={onKeyUp} />
|
||||||
@@ -98,7 +60,7 @@
|
|||||||
<div class="hidden max-w-[370px] sm:w-[50%] md:inline-block">
|
<div class="hidden max-w-[370px] sm:w-[50%] md:inline-block">
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<div slot="header">
|
<div slot="header">
|
||||||
<SidebarHeader {menuItems} />
|
<SidebarHeader createChannelForm={data.createChannelForm} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div slot="channels">
|
<div slot="channels">
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import type { LayoutLoad } from './$types';
|
|||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load = (async ({ parent }) => {
|
export const load = (async ({ data, parent }) => {
|
||||||
await parent();
|
await parent();
|
||||||
|
|
||||||
const response = await getAllChannels();
|
const response = await getAllChannels();
|
||||||
|
|
||||||
const channels = response as Channel[];
|
const channels = response as Channel[];
|
||||||
|
|
||||||
return { channels };
|
return { ...data, channels };
|
||||||
}) satisfies LayoutLoad;
|
}) satisfies LayoutLoad;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import { channelsCache } from '$lib/stores/cache';
|
import { channelsCache } from '$lib/stores/cache';
|
||||||
import type { Channel, Message } from '$lib/types';
|
import type { Channel, Message } from '$lib/types';
|
||||||
import { appWebsocket } from '$lib/stores/websocket';
|
import { appWebsocket } from '$lib/stores/websocket';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@@ -13,9 +12,10 @@
|
|||||||
$: channelId = parseInt($page.params.channel_id);
|
$: channelId = parseInt($page.params.channel_id);
|
||||||
$: channelsCache.get(channelId).then((c) => (channel = c));
|
$: channelsCache.get(channelId).then((c) => (channel = c));
|
||||||
|
|
||||||
const messages = data.messages;
|
$: messages = data.messages;
|
||||||
|
|
||||||
let channelArea: ChannelArea;
|
let channelArea: ChannelArea;
|
||||||
|
$: channelArea?.updateMessages(messages, true);
|
||||||
|
|
||||||
function handleCreateMessage(message: unknown) {
|
function handleCreateMessage(message: unknown) {
|
||||||
const typedMessage = message as Message;
|
const typedMessage = message as Message;
|
||||||
@@ -27,13 +27,7 @@
|
|||||||
channelArea?.updateMessages(messages);
|
channelArea?.updateMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
appWebsocket.on_autoDispose('createMessage', handleCreateMessage);
|
||||||
appWebsocket.on('createMessage', handleCreateMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
appWebsocket.off('createMessage', handleCreateMessage);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen w-full items-center justify-center">
|
<div class="flex h-screen w-full items-center justify-center">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PageLoad } from './$types';
|
|||||||
import { isErrorResponse } from '$lib/types';
|
import { isErrorResponse } from '$lib/types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
export const load = (async ({ params, parent }) => {
|
export const load = (async ({ params, parent }) => {
|
||||||
await parent();
|
await parent();
|
||||||
@@ -17,5 +18,7 @@ export const load = (async ({ params, parent }) => {
|
|||||||
|
|
||||||
if (isErrorResponse(messages)) return redirect(302, '/channels');
|
if (isErrorResponse(messages)) return redirect(302, '/channels');
|
||||||
|
|
||||||
|
console.log(messages);
|
||||||
|
|
||||||
return { messages };
|
return { messages };
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
@@ -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>
|
||||||
38
src/routes/(auth)/secrets/(components)/secrets-list.svelte
Normal file
38
src/routes/(auth)/secrets/(components)/secrets-list.svelte
Normal 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>
|
||||||
114
src/routes/(auth)/secrets/(forms)/secret-form.svelte
Normal file
114
src/routes/(auth)/secrets/(forms)/secret-form.svelte
Normal 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>
|
||||||
16
src/routes/(auth)/secrets/+layout.server.ts
Normal file
16
src/routes/(auth)/secrets/+layout.server.ts
Normal 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;
|
||||||
37
src/routes/(auth)/secrets/+layout.svelte
Normal file
37
src/routes/(auth)/secrets/+layout.svelte
Normal 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>
|
||||||
7
src/routes/(auth)/secrets/+page.svelte
Normal file
7
src/routes/(auth)/secrets/+page.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
let secrets = data.secrets;
|
||||||
|
</script>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/routes/(auth)/secrets/[secret_id=integer]/+page.svelte
Normal file
65
src/routes/(auth)/secrets/[secret_id=integer]/+page.svelte
Normal 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}
|
||||||
26
src/routes/(auth)/secrets/[secret_id=integer]/+page.ts
Normal file
26
src/routes/(auth)/secrets/[secret_id=integer]/+page.ts
Normal 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;
|
||||||
28
src/routes/(auth)/secrets/new/+page.server.ts
Normal file
28
src/routes/(auth)/secrets/new/+page.server.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/routes/(auth)/secrets/new/+page.svelte
Normal file
27
src/routes/(auth)/secrets/new/+page.svelte
Normal 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} />
|
||||||
16
src/routes/(auth)/secrets/new/+page.ts
Normal file
16
src/routes/(auth)/secrets/new/+page.ts
Normal 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;
|
||||||
5
src/routes/(auth)/settings/+layout.svelte
Normal file
5
src/routes/(auth)/settings/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
|
export let data: LayoutData;
|
||||||
|
</script>
|
||||||
5
src/routes/(auth)/settings/+page.svelte
Normal file
5
src/routes/(auth)/settings/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
</script>
|
||||||
@@ -3,11 +3,16 @@ import { zod } from 'sveltekit-superforms/adapters';
|
|||||||
import { loginFormSchema } from './login-form.svelte';
|
import { loginFormSchema } from './login-form.svelte';
|
||||||
import { superValidate } from 'sveltekit-superforms';
|
import { superValidate } from 'sveltekit-superforms';
|
||||||
import { type Actions, fail, redirect } from '@sveltejs/kit';
|
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';
|
import { isErrorResponse } from '$lib/types';
|
||||||
|
|
||||||
export const load = (async ({ cookies }) => {
|
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)) };
|
return { form: await superValidate(zod(loginFormSchema)) };
|
||||||
}) satisfies PageServerLoad;
|
}) satisfies PageServerLoad;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
import { theme } from '$lib/stores/theme';
|
import { theme } from '$lib/stores/theme';
|
||||||
|
import { Toaster } from '$lib/components/ui/sonner';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<Toaster theme={$theme} />
|
||||||
<div class="h-full w-full">
|
<div class="h-full w-full">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user