diff --git a/bun.lockb b/bun.lockb index 9931be6..33b43e6 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 72fa159..8a9d3e5 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,52 @@ { - "name": "nir-messenger", - "version": "0.0.1", - "private": true, - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check . && eslint .", - "format": "prettier --write ." - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^3.0.0", - "@sveltejs/kit": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "@types/eslint": "^8.56.0", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.35.1", - "prettier": "^3.1.1", - "prettier-plugin-svelte": "^3.1.2", - "svelte": "^4.2.7", - "svelte-check": "^3.6.0", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.0.3", - "tailwindcss": "^3.4.1", - "postcss": "^8.4.33", - "autoprefixer": "^10.4.16", - "prettier-plugin-tailwindcss": "^0.5.1" - }, - "type": "module", - "dependencies": { - "axios": "^1.6.8", - "bits-ui": "^0.21.7", - "clsx": "^2.1.1", - "formsnap": "^1.0.0", - "lucide-svelte": "^0.378.0", - "mode-watcher": "^0.3.0", - "svelte-persisted-store": "^0.9.2", - "svelte-sonner": "^0.3.24", - "sveltekit-superforms": "^2.13.1", - "tailwind-merge": "^2.3.0", - "tailwind-variants": "^0.2.1", - "zod": "^3.23.8" - } + "name": "nir-messenger", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@types/eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.35.1", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^4.2.7", + "svelte-check": "^3.6.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^5.0.3", + "tailwindcss": "^3.4.1", + "postcss": "^8.4.33", + "autoprefixer": "^10.4.16", + "prettier-plugin-tailwindcss": "^0.5.1" + }, + "type": "module", + "dependencies": { + "axios": "^1.6.8", + "bits-ui": "^0.21.8", + "clsx": "^2.1.1", + "cmdk-sv": "^0.0.17", + "formsnap": "^1.0.0", + "lucide-svelte": "^0.378.0", + "mode-watcher": "^0.3.0", + "svelte-persisted-store": "^0.9.2", + "svelte-sonner": "^0.3.24", + "sveltekit-superforms": "^2.13.1", + "tailwind-merge": "^2.3.0", + "tailwind-variants": "^0.2.1", + "zod": "^3.23.8" + } } diff --git a/src/lib/api/channel.ts b/src/lib/api/channel.ts index 5ff6691..e428b47 100644 --- a/src/lib/api/channel.ts +++ b/src/lib/api/channel.ts @@ -1,46 +1,55 @@ -import { messagesCache, channelsCache } from '$lib/stores/cache'; -import type { Channel, Message } from '../types'; +import { messagesCache, channelsCache, channelsUserCache } from '$lib/stores/cache'; +import type { Channel, ChannelUserPermissions, Message } from '../types'; import { apiRequest } from './utils'; -export async function getAllChannels() { - return await apiRequest('/channel', 'get', undefined, (data) => { +export async function getAllChannels(token?: string) { + return await apiRequest('/channel', 'get', { token }, (data) => { data.forEach((channel) => { channelsCache.set(channel.id, channel); }); }); } -export async function getChannelById(channelId: number) { - return await apiRequest(`/channel/${channelId}`, 'get', undefined, (data) => { +export async function getChannelById(channelId: number, token?: string) { + return await apiRequest(`/channel/${channelId}`, 'get', { token }, (data) => { channelsCache.set(data.id, data); }); } -export async function createChannel(name: string) { - return await apiRequest('/channel', 'post', { data: { name } }, (data) => { +export async function createChannel(name: string, token?: string) { + return await apiRequest('/channel', 'post', { data: { name }, token }, (data) => { channelsCache.set(data.id, data); }); } -export async function deleteChannel(channelId: number) { - return await apiRequest(`/channel/${channelId}`, 'delete', undefined, (data) => { +export async function deleteChannel(channelId: number, token?: string) { + return await apiRequest(`/channel/${channelId}`, 'delete', { token }, (data) => { channelsCache.remove(data.id); + channelsUserCache.remove(data.id); }); } -export async function addUserToChannel(channelId: number, userId: number) { - return await apiRequest(`/channel/${channelId}/user/${userId}`, 'post'); +export async function addUserToChannel(channelId: number, userId: number, token?: string) { + return await apiRequest(`/channel/${channelId}/user/${userId}`, 'post', { token }, async () => { + const channelUsers = await channelsUserCache.get(channelId) || new Set(); + channelUsers.add(userId); + channelsUserCache.set(channelId, channelUsers); + }); } -export async function removeUserFromChannel(channelId: number, userId: number) { - return await apiRequest(`/channel/${channelId}/user/${userId}`, 'delete'); +export async function removeUserFromChannel(channelId: number, userId: number, token?: string) { + return await apiRequest(`/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( - `/channel/${channelId}/message${beforeId || limit ? '?' : ''}${beforeId ? `before=${beforeId}` : ''}${limit ? `&limit=${limit}` : ''}`, + `/channel/${channelId}/message`, 'get', - undefined, + { params: { before: beforeId, limit }, token }, (data) => { data.forEach((message) => { messagesCache.set(message.id, message); @@ -48,3 +57,7 @@ export async function getMessagesByChannelId(channelId: number, beforeId?: numbe } ); } + +export async function getChannelUserPermissions(channelId: number, userId: number, token?: string) { + return await apiRequest(`/channel/${channelId}/users/${userId}/permissions`, 'get', { token }); +} diff --git a/src/lib/api/notification.ts b/src/lib/api/notification.ts new file mode 100644 index 0000000..3b50d32 --- /dev/null +++ b/src/lib/api/notification.ts @@ -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/${notificationId}`, 'get', { token }, (data) => { + notificationsCache.set(data.id, data); + }); +} + +export async function getAllNotifications(limit?: number, offset?: number, token?: string) { + return await apiRequest('/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(`/notification/${notificationId}`, 'post', { token }, async () => { + const notification = await notificationsCache.get(notificationId); + if (notification) { + notificationsCache.set(notificationId, { ...notification, seen: true }); + } + }); +} diff --git a/src/lib/api/secret.ts b/src/lib/api/secret.ts new file mode 100644 index 0000000..9b2f9fb --- /dev/null +++ b/src/lib/api/secret.ts @@ -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/${secretId}`, 'get', { token }, (data) => { + secretCache.set(data.id, data); + }); +} + +export async function getSecretsBySelf(token?: string) { + return await apiRequest(`/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', '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/${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/${secretId}`, 'delete', { token }, (data) => { + secretCache.remove(data.id); + }); +} + +export async function addSecretRecipient(secretId: number, recipientId: number, token?: string) { + return await apiRequest(`/secret/${secretId}/recipient/${recipientId}`, 'post', { token }); +} + +export async function removeSecretRecipient(secretId: number, recipientId: number, token?: string) { + return await apiRequest(`/secret/${secretId}/recipient/${recipientId}`, 'delete', { token }); +} + +export async function getSecretRecipients(secretId: number, token?: string) { + return await apiRequest(`/secret/${secretId}/recipients`, 'get', { token }, (data) => { + data.forEach((user) => { + usersCache.set(user.id, user); + }); + }); +} \ No newline at end of file diff --git a/src/lib/api/user.ts b/src/lib/api/user.ts index e271a63..2195360 100644 --- a/src/lib/api/user.ts +++ b/src/lib/api/user.ts @@ -1,4 +1,4 @@ -import { usersCache } from '$lib/stores/cache'; +import { channelsUserCache, followingUserCache, usersCache } from '$lib/stores/cache'; import type { Token, User } from '../types'; import { apiRequest } from './utils'; @@ -32,3 +32,47 @@ export async function loginUser(username: string, password: string) { export async function registerUser(username: string, password: string) { return await apiRequest('/user/register', 'post', { data: { username, password } }); } + +export async function getFollowedUsers(token?: string) { + return await apiRequest('/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(`/user/${userId}/follow`, 'get', undefined, data => { + followingUserCache.set(userId, data); + }); +} + +export async function followUser(userId: number) { + return await apiRequest(`/user/${userId}/follow`, 'post', undefined, () => { + followingUserCache.set(userId, true); + }); +} + +export async function unfollowUser(userId: number) { + return await apiRequest(`/user/${userId}/follow`, 'delete', undefined, () => { + followingUserCache.set(userId, false); + }); +} + +export async function getUsersByChannelId(channelId: number, token?: string) { + return await apiRequest(`/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/search`, 'get', { params: { query, limit, offset }, token }, (data) => { + data.forEach((user) => { + usersCache.set(user.id, user); + }); + }); +} diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index 90ed6e7..27868c8 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -12,7 +12,7 @@ export async function apiRequest( ): Promise { 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(); diff --git a/src/lib/components/channel-context.svelte b/src/lib/components/channel-context.svelte new file mode 100644 index 0000000..70dbc00 --- /dev/null +++ b/src/lib/components/channel-context.svelte @@ -0,0 +1,171 @@ + + + + + + + Are you absolutely sure? + + This action cannot be undone. + + + + Cancel + deleteChannel(channel.id)}> + Continue + + + + + + addUserToChannel(channel.id, user.id)} +/> + + removeUserFromChannel(channel.id, user.id)} +/> + + + + + + + + + + {channel.name} + + handleInvite()}> + + Invite + + {#if permissions && permissions.admin} + (kickMemberDialogOpen = true)}> + + Kick member + + {/if} + {#if permissions && permissions.admin} + + (deleteChannelDialogOpen = true)}> + + Delete + + {/if} + + diff --git a/src/lib/components/notifications.svelte b/src/lib/components/notifications.svelte new file mode 100644 index 0000000..5a2166c --- /dev/null +++ b/src/lib/components/notifications.svelte @@ -0,0 +1,65 @@ + + + + +
+ +
+
+ {#if notifications.length > 0} + + +
Notifications
+ {#each sortedNotifications as notification} + +
+
{notification.title}
+
{notification.body}
+
+ {new Date(notification.createdAt).toLocaleString()} +
+
+ {/each} +
+
+ {/if} +
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte new file mode 100644 index 0000000..57d643b --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte new file mode 100644 index 0000000..ef0a953 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte new file mode 100644 index 0000000..256a5ff --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte new file mode 100644 index 0000000..18acce9 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte new file mode 100644 index 0000000..a235d1f --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte @@ -0,0 +1,16 @@ + + +
+ +
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte new file mode 100644 index 0000000..2650ef9 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte new file mode 100644 index 0000000..3081d75 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte new file mode 100644 index 0000000..e227219 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte @@ -0,0 +1,9 @@ + + + + + diff --git a/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte new file mode 100644 index 0000000..7f98004 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/lib/components/ui/alert-dialog/index.ts b/src/lib/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..be56dd7 --- /dev/null +++ b/src/lib/components/ui/alert-dialog/index.ts @@ -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, +}; diff --git a/src/lib/components/ui/checkbox/checkbox.svelte b/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..d203953 --- /dev/null +++ b/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,35 @@ + + + + + {#if isChecked} + + {:else if isIndeterminate} + + {/if} + + diff --git a/src/lib/components/ui/checkbox/index.ts b/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/src/lib/components/ui/command/command-dialog.svelte b/src/lib/components/ui/command/command-dialog.svelte new file mode 100644 index 0000000..c6bb11a --- /dev/null +++ b/src/lib/components/ui/command/command-dialog.svelte @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/src/lib/components/ui/command/command-empty.svelte b/src/lib/components/ui/command/command-empty.svelte new file mode 100644 index 0000000..3a0819d --- /dev/null +++ b/src/lib/components/ui/command/command-empty.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/src/lib/components/ui/command/command-group.svelte b/src/lib/components/ui/command/command-group.svelte new file mode 100644 index 0000000..0d78a28 --- /dev/null +++ b/src/lib/components/ui/command/command-group.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/src/lib/components/ui/command/command-input.svelte b/src/lib/components/ui/command/command-input.svelte new file mode 100644 index 0000000..c9d9d38 --- /dev/null +++ b/src/lib/components/ui/command/command-input.svelte @@ -0,0 +1,23 @@ + + +
+ + +
diff --git a/src/lib/components/ui/command/command-item.svelte b/src/lib/components/ui/command/command-item.svelte new file mode 100644 index 0000000..63bbdac --- /dev/null +++ b/src/lib/components/ui/command/command-item.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/src/lib/components/ui/command/command-list.svelte b/src/lib/components/ui/command/command-list.svelte new file mode 100644 index 0000000..8ceda03 --- /dev/null +++ b/src/lib/components/ui/command/command-list.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/components/ui/command/command-separator.svelte b/src/lib/components/ui/command/command-separator.svelte new file mode 100644 index 0000000..75caf5b --- /dev/null +++ b/src/lib/components/ui/command/command-separator.svelte @@ -0,0 +1,10 @@ + + + diff --git a/src/lib/components/ui/command/command-shortcut.svelte b/src/lib/components/ui/command/command-shortcut.svelte new file mode 100644 index 0000000..b327ccb --- /dev/null +++ b/src/lib/components/ui/command/command-shortcut.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/components/ui/command/command.svelte b/src/lib/components/ui/command/command.svelte new file mode 100644 index 0000000..0e2ce48 --- /dev/null +++ b/src/lib/components/ui/command/command.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/src/lib/components/ui/command/index.ts b/src/lib/components/ui/command/index.ts new file mode 100644 index 0000000..d8a2e7c --- /dev/null +++ b/src/lib/components/ui/command/index.ts @@ -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, +}; diff --git a/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte b/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte new file mode 100644 index 0000000..452d40f --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-checkbox-item.svelte @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-content.svelte b/src/lib/components/ui/context-menu/context-menu-content.svelte new file mode 100644 index 0000000..952ca50 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-content.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-item.svelte b/src/lib/components/ui/context-menu/context-menu-item.svelte new file mode 100644 index 0000000..cd91b8b --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-item.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-label.svelte b/src/lib/components/ui/context-menu/context-menu-label.svelte new file mode 100644 index 0000000..5d52f79 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-label.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-radio-group.svelte b/src/lib/components/ui/context-menu/context-menu-radio-group.svelte new file mode 100644 index 0000000..53fa692 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-radio-group.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-radio-item.svelte b/src/lib/components/ui/context-menu/context-menu-radio-item.svelte new file mode 100644 index 0000000..a0ef943 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-radio-item.svelte @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-separator.svelte b/src/lib/components/ui/context-menu/context-menu-separator.svelte new file mode 100644 index 0000000..8dc3a61 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-separator.svelte @@ -0,0 +1,14 @@ + + + diff --git a/src/lib/components/ui/context-menu/context-menu-shortcut.svelte b/src/lib/components/ui/context-menu/context-menu-shortcut.svelte new file mode 100644 index 0000000..09ab9f8 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-shortcut.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/src/lib/components/ui/context-menu/context-menu-sub-content.svelte new file mode 100644 index 0000000..20f7e75 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-sub-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte b/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte new file mode 100644 index 0000000..2a5f7f5 --- /dev/null +++ b/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte @@ -0,0 +1,32 @@ + + + + + + diff --git a/src/lib/components/ui/context-menu/index.ts b/src/lib/components/ui/context-menu/index.ts new file mode 100644 index 0000000..7d4af84 --- /dev/null +++ b/src/lib/components/ui/context-menu/index.ts @@ -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, +}; diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..9512ba8 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,36 @@ + + + + + + + + + Close + + + diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..e1d796a --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/components/ui/dialog/dialog-footer.svelte b/src/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..a235d1f --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,16 @@ + + +
+ +
diff --git a/src/lib/components/ui/dialog/dialog-header.svelte b/src/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..6b4448c --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..3721361 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/ui/dialog/dialog-portal.svelte b/src/lib/components/ui/dialog/dialog-portal.svelte new file mode 100644 index 0000000..eb5d0a5 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-portal.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..06574f3 --- /dev/null +++ b/src/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..b17ba5e --- /dev/null +++ b/src/lib/components/ui/dialog/index.ts @@ -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, +}; diff --git a/src/lib/components/user-context.svelte b/src/lib/components/user-context.svelte new file mode 100644 index 0000000..1547309 --- /dev/null +++ b/src/lib/components/user-context.svelte @@ -0,0 +1,68 @@ + + + + + + + Are you absolutely sure? + + This action cannot be undone. + + + + Cancel + unfollowUser(user.id)}>Continue + + + + + + + + + + + {user.username} + + {#if isFollowing} + (unfollowDialogOpen = true)}> + + Unfollow + + {:else} + followUser(user.id)}> + + Follow + + {/if} + + diff --git a/src/lib/components/user-search.svelte b/src/lib/components/user-search.svelte new file mode 100644 index 0000000..eccba40 --- /dev/null +++ b/src/lib/components/user-search.svelte @@ -0,0 +1,68 @@ + + + + + + + + + No results found. + {#each users as user} + { + onSelect(user); + open = false; + }} + > +
+ + + {user.username[0].toUpperCase()} + + {user.username} +
+
+ {/each} +
+
+
+
diff --git a/src/lib/event.ts b/src/lib/event.ts index b39fda7..9764fd8 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -1,15 +1,17 @@ +import { onDestroy, onMount } from "svelte"; + export interface Listener { (event: D): unknown; } -export interface Disposable { +export interface ListenerDisposable { dispose(): void; } export class EventEmitter { private listeners: Map[]> = new Map(); - on = (event: E, listener: Listener): Disposable => { + on = (event: E, listener: Listener): ListenerDisposable => { if (!this.listeners.has(event)) this.listeners.set(event, []); this.listeners.get(event)?.push(listener); @@ -19,6 +21,18 @@ export class EventEmitter { }; }; + 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) => { if (!this.listeners.has(event)) return; @@ -33,7 +47,7 @@ export class EventEmitter { this.listeners.get(event)?.forEach((listener) => setTimeout(() => listener(data), 0)); }; - pipe = (event: E, te: EventEmitter): Disposable => { + pipe = (event: E, te: EventEmitter): ListenerDisposable => { return this.on(event, (e) => te.emit(event, e)); }; } diff --git a/src/lib/stores/cache/index.ts b/src/lib/stores/cache/index.ts index 66b1ea3..83df4c9 100644 --- a/src/lib/stores/cache/index.ts +++ b/src/lib/stores/cache/index.ts @@ -1,23 +1,49 @@ import { getChannelById } from '$lib/api/channel'; import { getMessageById } from '$lib/api/message'; -import { getUserById } from '$lib/api/user'; -import { isErrorResponse, type Channel, type Message, type User } from '$lib/types'; +import { getNotificationById } from '$lib/api/notification'; +import { getSecretById, getSecretRecipients } from '$lib/api/secret'; +import { getUserById, getUsersByChannelId, isFollowing } from '$lib/api/user'; +import { dataOrNull, type Channel, type Message, type Secret, type User, type Notification } from '$lib/types'; import { Cache } from './utils'; -export const usersCache: Cache = new Cache(async (id) => { +export const usersCache: Cache = new Cache('User', async (id) => { const response = await getUserById(id); - if (isErrorResponse(response)) return null; - return response; + return dataOrNull(response); }); -export const messagesCache: Cache = new Cache(async (id) => { +export const messagesCache: Cache = new Cache('Message', async (id) => { const response = await getMessageById(id); - if (isErrorResponse(response)) return null; - return response; + return dataOrNull(response); }); -export const channelsCache: Cache = new Cache(async (id) => { +export const channelsCache: Cache = new Cache('Channel', async (id) => { const response = await getChannelById(id); - if (isErrorResponse(response)) return null; - return response; + return dataOrNull(response); +}); + +type Following = boolean; + +export const followingUserCache: Cache = new Cache('Following', async (id) => { + const response = await isFollowing(id); + return dataOrNull(response); +}); + +export const secretCache: Cache = new Cache('Secret', async (id) => { + const response = await getSecretById(id); + return dataOrNull(response); +}); + +export const secretRecipientsCache: Cache> = new Cache('SecretRecipients', async (id) => { + const response = await getSecretRecipients(id); + return new Set(dataOrNull(response)?.map((user) => user.id)) || null; +}); + +export const notificationsCache: Cache = new Cache('Notifications', async (id) => { + const response = await getNotificationById(id); + return dataOrNull(response); +}); + +export const channelsUserCache: Cache> = new Cache('ChannelsUser', async (id) => { + const response = await getUsersByChannelId(id); + return new Set(dataOrNull(response)?.map((user) => user.id)) || null; }); diff --git a/src/lib/stores/cache/utils.ts b/src/lib/stores/cache/utils.ts index 05b52e7..ca9ef3e 100644 --- a/src/lib/stores/cache/utils.ts +++ b/src/lib/stores/cache/utils.ts @@ -1,19 +1,26 @@ +import { EventEmitter } from '$lib/event'; import { get, writable, type Writable } from 'svelte/store'; +export type CacheEvent = 'add' | 'update' | 'remove'; + export class Cache { private data: Writable> = writable(new Map()); private runningCaches: Set = new Set(); + private eventEmitter = new EventEmitter(); + + private name: string; private resolver: (data: I) => Promise; - constructor(resolver: (data: I) => Promise) { + constructor(name: string, resolver: (data: I) => Promise) { + this.name = name; this.resolver = resolver; } async get(key: I): Promise { const cached = get(this.data).get(key); if (cached) { - console.log(`[Cache] Found in cache: `, cached); + console.log(`[Cache] Found in cache ${key}/${this.name}: `, cached); return cached; } @@ -34,24 +41,56 @@ export class Cache { this.runningCaches.delete(key); - if (data) - console.log(`[Cache] Added to cache: `, data); - return data; } set(key: I, value: T) { - console.log(`[Cache] Added to cache: `, value); + const data = get(this.data); + + if (data.has(key)) { + console.log(`[Cache] Updated cache ${key}/${this.name}: `, value); + this.eventEmitter.emit('update', [key, value]); + } + else { + console.log(`[Cache] Added to cache ${key}/${this.name}: `, value); + this.eventEmitter.emit('add', [key, value]); + } this.data.update((data) => data.set(key, value)); } remove(key: I) { - console.log(`[Cache] Removed from cache: `, key); + console.log(`[Cache] Removed from cache ${key}/${this.name}: `, key); this.data.update((data) => { data.delete(key); return data; }); + + this.eventEmitter.emit('remove', [key, null]); + } + + subscribe(event: CacheEvent, callback: (data: [I, T | null]) => void) { + return this.eventEmitter.on(event, callback); + } + + subscribeKey(key: I, event: CacheEvent, callback: (data: T | null) => void) { + return this.subscribe(event, (data) => { + if (data[0] === key) callback(data[1]); + }); + } + + unsubscribe(event: CacheEvent, callback: (data: [I, T | null]) => void) { + return this.eventEmitter.off(event, callback); + } + + subscribe_autoDispose(event: CacheEvent, callback: (data: [I, T | null]) => void) { + this.eventEmitter.on_autoDispose(event, callback); + } + + subscribeKey_autoDispose(key: I, event: CacheEvent, callback: (data: T | null) => void) { + this.subscribe_autoDispose(event, (data) => { + if (data[0] === key) callback(data[1]); + }); } } diff --git a/src/lib/stores/websocket.ts b/src/lib/stores/websocket.ts index e70c08d..79ae6c1 100644 --- a/src/lib/stores/websocket.ts +++ b/src/lib/stores/websocket.ts @@ -2,26 +2,56 @@ import { BASE_API_URL } from '$lib/constants'; import { EventEmitter } from '$lib/event'; import { derived, get } from 'svelte/store'; import { token as tokenStore } from './user'; -import type { Channel, Message } from '$lib/types'; -import { messagesCache, channelsCache } from './cache'; +import type { Channel, Message, Secret, Notification } from '$lib/types'; +import { messagesCache, channelsCache, channelsUserCache, secretCache, notificationsCache, followingUserCache } from './cache'; +import { browser } from '$app/environment'; export type WebSocketMessageType = | 'createMessage' | 'updateChannel' | 'createChannel' | 'deleteChannel' + | 'addedUserToChannel' + | 'removedUserFromChannel' + | 'createSecret' + | 'updateSecret' + | 'secretRecipientAdded' + | 'secretRecipientDeleted' + | 'deleteSecret' + | 'createNotification' + | 'seenNotification' + | 'followUser' + | 'unfollowUser' + | 'connect' | 'disconnect' | 'any'; +type Id = { + id: number; +} + +type IdUser = Id & { + user_id: number; +} + +type UserIdChannelId = { + user_id: number; + channel_id: number; +} + export type WebSocketMessageData = | Message | Channel - | { id: number } + | Secret + | Notification + | Id + | IdUser | null + | UserIdChannelId | { - type: WebSocketMessageType; - }; + type: WebSocketMessageType; + }; export type WebsoketMessage = { type: WebSocketMessageType; @@ -34,7 +64,7 @@ appWebsocket.on('any', (data) => { console.log(`[WS] Recieved message: `, data); }); -function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) { +async function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) { switch (type) { case 'createMessage': messagesCache.set((data as Message).id, data as Message); @@ -46,7 +76,50 @@ function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) { channelsCache.set((data as Channel).id, data as Channel); break; case 'deleteChannel': - channelsCache.remove((data as { id: number }).id); + channelsCache.remove((data as Id).id); + break; + case 'addedUserToChannel': { + const { user_id, channel_id } = data as UserIdChannelId; + + const channelUsers = await channelsUserCache.get(channel_id) || new Set(); + channelUsers.add(user_id); + + channelsUserCache.set(channel_id, channelUsers); + } + break; + case 'removedUserFromChannel': { + const { user_id, channel_id } = data as UserIdChannelId; + + const channelUsers = await channelsUserCache.get(channel_id) || new Set(); + channelUsers.delete(user_id); + + channelsUserCache.set(channel_id, channelUsers); + } + break; + case 'createSecret': + secretCache.set((data as Secret).id, data as Secret); + break; + case 'updateSecret': + secretCache.set((data as Secret).id, data as Secret); + break; + case 'deleteSecret': + secretCache.remove((data as Id).id); + break; + case 'createNotification': + notificationsCache.set((data as Notification).id, data as Notification); + break; + case 'seenNotification': { + const notification = await notificationsCache.get((data as Id).id); + if (notification) { + notificationsCache.set(notification.id, { ...notification, seen: true }); + } + } + break; + case 'followUser': + followingUserCache.set((data as IdUser).user_id, true); + break; + case 'unfollowUser': + followingUserCache.set((data as IdUser).user_id, false); break; default: break; @@ -54,6 +127,8 @@ function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) { } const connect = (token: string) => { + if (!browser) + return null; const websocket = new WebSocket(`ws://${BASE_API_URL}/ws/${token}`); websocket.onopen = () => { diff --git a/src/lib/types.ts b/src/lib/types.ts index 3cac946..35aba94 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -6,6 +6,11 @@ export function isErrorResponse(data: unknown): data is ErrorResponse { return (data as ErrorResponse).error !== undefined; } +export function dataOrNull(data: T | ErrorResponse): T | null { + if (isErrorResponse(data)) return null; + return data; +} + export type Token = { token: string; userId: number; @@ -17,6 +22,7 @@ export type User = { id: number; username: string; avatar?: string; + lastSeen: string; createdAt: string; }; @@ -26,6 +32,7 @@ export type Message = { authorId: number; content: string; createdAt: string; + system: boolean; }; export type Channel = { @@ -34,3 +41,28 @@ export type Channel = { lastMessageId?: number; createdAt: string; }; + +export type Secret = { + id: number; + userId: number; + name: string; + content: string; + timeoutSeconds: number; + expired: boolean; + createdAt: string; +}; + +export type ChannelUserPermissions = { + userId: number; + channelId: number; + admin: boolean; +}; + +export type Notification = { + id: number; + userId: number; + title: string; + body: string; + seen: boolean; + createdAt: string; +}; \ No newline at end of file diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte index b2b5a2a..a27102f 100644 --- a/src/routes/(auth)/+layout.svelte +++ b/src/routes/(auth)/+layout.svelte @@ -3,6 +3,7 @@ import { token } from '$lib/stores/user'; import { usersCache } from '$lib/stores/cache'; import { appWebsocket } from '$lib/stores/websocket'; + import { toast } from 'svelte-sonner'; export let data: LayoutData; @@ -10,6 +11,16 @@ const user = data.user; usersCache.set(user.id, user); + + appWebsocket.on_autoDispose('createNotification', (data) => { + const typedNotification = data as unknown as Notification; + + console.log('createNotification', typedNotification); + + toast.info(typedNotification.body, { + position: 'bottom-right' + }); + }); diff --git a/src/routes/(auth)/channels/(components)/(channel)/channel-area.svelte b/src/routes/(auth)/channels/(components)/(channel)/channel-area.svelte index 4fc8c14..c1f25f7 100644 --- a/src/routes/(auth)/channels/(components)/(channel)/channel-area.svelte +++ b/src/routes/(auth)/channels/(components)/(channel)/channel-area.svelte @@ -10,6 +10,7 @@ export let messages: MessageType[] = []; let messageArea: MessageArea; + let scrollToBottom = false; const sendMessage = (content: string) => { if (!channel) return; @@ -17,12 +18,19 @@ createMessage(channel.id, content); }; - export function updateMessages(newMessages: Message[]) { + export function updateMessages(newMessages: Message[], scrollToBottom: boolean = false) { messages = newMessages; + + scrollToBottom = scrollToBottom; } afterUpdate(() => { if (!messageArea) return; + if (scrollToBottom) { + messageArea.scroll('bottom', 'instant'); + scrollToBottom = false; + } + if (messageArea.getScrollPercent() > 0.95) messageArea.scroll('bottom', 'smooth'); }); @@ -41,7 +49,9 @@
- + {#key messages} + + {/key}
diff --git a/src/routes/(auth)/channels/(components)/(channel)/message.svelte b/src/routes/(auth)/channels/(components)/(channel)/message.svelte index bdeac4a..4d54255 100644 --- a/src/routes/(auth)/channels/(components)/(channel)/message.svelte +++ b/src/routes/(auth)/channels/(components)/(channel)/message.svelte @@ -3,43 +3,53 @@ import { user } from '$lib/stores/user'; import { cn } from '$lib/utils'; import * as Avatar from '$lib/components/ui/avatar'; - import { usersCache } from '$lib/stores/cache'; + import { usersCache, messagesCache } from '$lib/stores/cache'; import { writable, type Writable } from 'svelte/store'; + import { onDestroy, onMount } from 'svelte'; + import type { ListenerDisposable } from '$lib/event'; + import UserContext from '$lib/components/user-context.svelte'; export let message: Message; - let sender: Writable = writable(null); + let sender = usersCache.get(message.authorId) as Promise; - usersCache.get(message.authorId).then((user) => ($sender = user)); - - $: username = (isSelf ? $user?.username : $sender?.username) || 'N'; $: isSelf = $user?.id === message.authorId; $: color = isSelf ? 'bg-accent' : 'bg-secondary'; $: position = isSelf ? 'justify-end' : 'justify-start'; $: timestampPosition = isSelf ? 'text-right' : 'text-left'; + + function updateMessage(cachedMessage: Message | null) { + message = cachedMessage || message; + } + + messagesCache.subscribeKey_autoDispose(message.id, 'update', updateMessage); -
-
- {#if !isSelf} - - {username[0].toUpperCase()} - - {/if} +{#await sender then sender} +
+
+ {#if !isSelf} + + + {sender.username[0].toUpperCase()} + + + {/if} -
- - {message.content} - - {new Date(message.createdAt).toLocaleString()} - +
+ + {message.content} + + {new Date(message.createdAt).toLocaleString()} + +
+ {#if isSelf} + + {sender.username[0].toUpperCase()} + + {/if}
- {#if isSelf} - - {username[0].toUpperCase()} - - {/if}
-
+{/await} diff --git a/src/routes/(auth)/channels/(components)/(sidebar)/channel-list-item.svelte b/src/routes/(auth)/channels/(components)/(sidebar)/channel-list-item.svelte new file mode 100644 index 0000000..f36bd54 --- /dev/null +++ b/src/routes/(auth)/channels/(components)/(sidebar)/channel-list-item.svelte @@ -0,0 +1,78 @@ + + + + + diff --git a/src/routes/(auth)/channels/(components)/channel-list.svelte b/src/routes/(auth)/channels/(components)/(sidebar)/channel-list.svelte similarity index 83% rename from src/routes/(auth)/channels/(components)/channel-list.svelte rename to src/routes/(auth)/channels/(components)/(sidebar)/channel-list.svelte index 3fdace5..c3275da 100644 --- a/src/routes/(auth)/channels/(components)/channel-list.svelte +++ b/src/routes/(auth)/channels/(components)/(sidebar)/channel-list.svelte @@ -17,6 +17,14 @@ export function deselect() { selectedChannel.set(undefined); } + + export function getSelectedId() { + return $selectedChannel; + } + + export function updateChannels(newChannels: Channel[]) { + channels = newChannels; + }
diff --git a/src/routes/(auth)/channels/(components)/(sidebar)/profile-dropdown.svelte b/src/routes/(auth)/channels/(components)/(sidebar)/profile-dropdown.svelte new file mode 100644 index 0000000..ac9b68c --- /dev/null +++ b/src/routes/(auth)/channels/(components)/(sidebar)/profile-dropdown.svelte @@ -0,0 +1,131 @@ + + + + + followUser(user.id)} + onQueryUpdate={getQueryUsers} +/> + + + + + + + My Account + + {#each menuItems as item} + {#if item && 'name' in item} +
+ + {item.name} +
+ {:else} + + {/if} + {/each} +
+
diff --git a/src/routes/(auth)/channels/(components)/(sidebar)/sidebar-header.svelte b/src/routes/(auth)/channels/(components)/(sidebar)/sidebar-header.svelte new file mode 100644 index 0000000..97a761d --- /dev/null +++ b/src/routes/(auth)/channels/(components)/(sidebar)/sidebar-header.svelte @@ -0,0 +1,60 @@ + + +
+
+ +
+ +
+ {$user?.username} +
+ +
+ seenNotification(notification.id)} + /> +
+ +
+ +
+
diff --git a/src/routes/(auth)/channels/(components)/sidebar.svelte b/src/routes/(auth)/channels/(components)/(sidebar)/sidebar.svelte similarity index 100% rename from src/routes/(auth)/channels/(components)/sidebar.svelte rename to src/routes/(auth)/channels/(components)/(sidebar)/sidebar.svelte diff --git a/src/routes/(auth)/channels/(components)/channel-list-item.svelte b/src/routes/(auth)/channels/(components)/channel-list-item.svelte deleted file mode 100644 index 59eb304..0000000 --- a/src/routes/(auth)/channels/(components)/channel-list-item.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/src/routes/(auth)/channels/(components)/sidebar-header.svelte b/src/routes/(auth)/channels/(components)/sidebar-header.svelte deleted file mode 100644 index e505cee..0000000 --- a/src/routes/(auth)/channels/(components)/sidebar-header.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - - -
- - - - - - My Account - - {#each menuItems as item} -
- - {item.name} -
- {/each} -
-
- -
- {$user?.username} -
- -
- -
-
diff --git a/src/routes/(auth)/channels/(forms)/add-user-to-channel/+page.server.ts b/src/routes/(auth)/channels/(forms)/add-user-to-channel/+page.server.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/(auth)/channels/(forms)/add-user-to-channel/add-user-to-channel-dialog.svelte b/src/routes/(auth)/channels/(forms)/add-user-to-channel/add-user-to-channel-dialog.svelte new file mode 100644 index 0000000..0b7973d --- /dev/null +++ b/src/routes/(auth)/channels/(forms)/add-user-to-channel/add-user-to-channel-dialog.svelte @@ -0,0 +1,26 @@ + + + + + + Create Channel + + { + if (data?.success) open = false; + }} + /> + + + + diff --git a/src/routes/(auth)/channels/(forms)/add-user-to-channel/add-user-to-channel-form.svelte b/src/routes/(auth)/channels/(forms)/add-user-to-channel/add-user-to-channel-form.svelte new file mode 100644 index 0000000..0820c31 --- /dev/null +++ b/src/routes/(auth)/channels/(forms)/add-user-to-channel/add-user-to-channel-form.svelte @@ -0,0 +1,41 @@ + + + + +
+ +
\ No newline at end of file diff --git a/src/routes/(auth)/channels/(forms)/create-channel/+page.server.ts b/src/routes/(auth)/channels/(forms)/create-channel/+page.server.ts new file mode 100644 index 0000000..42a1d49 --- /dev/null +++ b/src/routes/(auth)/channels/(forms)/create-channel/+page.server.ts @@ -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>, 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; +} \ No newline at end of file diff --git a/src/routes/(auth)/channels/(forms)/create-channel/create-channel-dialog.svelte b/src/routes/(auth)/channels/(forms)/create-channel/create-channel-dialog.svelte new file mode 100644 index 0000000..4617cf2 --- /dev/null +++ b/src/routes/(auth)/channels/(forms)/create-channel/create-channel-dialog.svelte @@ -0,0 +1,26 @@ + + + + + + Create Channel + + { + if (data?.success) open = false; + }} + /> + + + + diff --git a/src/routes/(auth)/channels/(forms)/create-channel/create-channel-form.svelte b/src/routes/(auth)/channels/(forms)/create-channel/create-channel-form.svelte new file mode 100644 index 0000000..57a2580 --- /dev/null +++ b/src/routes/(auth)/channels/(forms)/create-channel/create-channel-form.svelte @@ -0,0 +1,52 @@ + + + + +
+
+ + + Name + + + + +
+ Create +
+
+
diff --git a/src/routes/(auth)/channels/+layout.server.ts b/src/routes/(auth)/channels/+layout.server.ts new file mode 100644 index 0000000..89fffb2 --- /dev/null +++ b/src/routes/(auth)/channels/+layout.server.ts @@ -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; \ No newline at end of file diff --git a/src/routes/(auth)/channels/+layout.svelte b/src/routes/(auth)/channels/+layout.svelte index 8cf3ae6..f115f69 100644 --- a/src/routes/(auth)/channels/+layout.svelte +++ b/src/routes/(auth)/channels/+layout.svelte @@ -2,17 +2,12 @@ import type { LayoutData } from './$types'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; - import Sidebar from './(components)/sidebar.svelte'; - import SidebarHeader from './(components)/sidebar-header.svelte'; - import { type Icon } from 'lucide-svelte'; - import Settings from 'lucide-svelte/icons/settings'; - import LogOut from 'lucide-svelte/icons/log-out'; - import { onDestroy, onMount, type ComponentType } from 'svelte'; - import type { MenuItem } from './(components)/sidebar-header.svelte'; - import ChannelList from './(components)/channel-list.svelte'; import { appWebsocket, type WebSocketMessageType } from '$lib/stores/websocket'; import type { Channel } from '$lib/types'; + import Sidebar from './(components)/(sidebar)/sidebar.svelte'; + import SidebarHeader from './(components)/(sidebar)/sidebar-header.svelte'; + import ChannelList from './(components)/(sidebar)/channel-list.svelte'; export let data: LayoutData; @@ -21,29 +16,12 @@ let channelList: ChannelList | undefined; function onKeyUp(event: KeyboardEvent) { - if (event.key === 'Escape') { - channelList?.deselect(); - goto('/channels'); - } + // if (event.key === 'Escape') { + // channelList?.deselect(); + // goto('/channels'); + // } } - const menuItems: MenuItem[] = [ - { - name: 'Settings', - icon: Settings as ComponentType, - onClick: () => { - goto('/settings'); - } - }, - { - name: 'Logout', - icon: LogOut as ComponentType, - onClick: () => { - goto('/logout'); - } - } - ]; - $: channelId = parseInt($page.params.channel_id); function handleChannelCreated(channel: unknown) { @@ -52,19 +30,7 @@ if (!channelList) return; channels.push(typedChannel); - } - - function handleChannelUpdated(channel: unknown) { - const typedChannel = channel as Channel; - - if (!channelList) return; - - for (let i = 0; i < channels.length; i++) { - if (channels[i].id == typedChannel.id) { - channels[i] = typedChannel; - break; - } - } + channelList.updateChannels(channels); } function handleChannelDeleted(channel_id: unknown) { @@ -73,23 +39,19 @@ if (!channelList) return; channels = channels.filter((c) => c.id != id); + + if (channelId == id) goto('/channels'); + + channelList.updateChannels(channels); } const handlers = { createChannel: handleChannelCreated, - updateChannel: handleChannelUpdated, deleteChannel: handleChannelDeleted }; - onMount(() => { - for (const [key, value] of Object.entries(handlers)) - appWebsocket.on(key as WebSocketMessageType, value); - }); - - onDestroy(() => { - for (const [key, value] of Object.entries(handlers)) - appWebsocket.off(key as WebSocketMessageType, value); - }); + for (const [key, callback] of Object.entries(handlers)) + appWebsocket.on_autoDispose(key as WebSocketMessageType, callback); @@ -98,7 +60,7 @@