1
0
This commit is contained in:
2024-05-20 12:20:22 +03:00
parent e87b740e29
commit 13b6c19097
102 changed files with 1569 additions and 1346 deletions

View File

@@ -3,10 +3,7 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",

View File

@@ -4,67 +4,48 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--ring: 222.2 84% 4.9%;
--background: 253 44% 98%;
--foreground: 253 58% 0%;
--muted: 253 7% 87%;
--muted-foreground: 253 13% 37%;
--popover: 253 44% 98%;
--popover-foreground: 253 58% 0%;
--card: 253 44% 97%;
--card-foreground: 0 0% 0%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--primary: 253 91% 58%;
--primary-foreground: 253 91% 98%;
--secondary: 253 5% 89%;
--secondary-foreground: 253 5% 29%;
--accent: 253 12% 82%;
--accent-foreground: 253 12% 22%;
--destructive: 339.2 90.36% 51.18%;
--destructive-foreground: 0 0% 100%;
--ring: 253 91% 58%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--ring: hsl(212.7,26.8%,83.9);
--background: 253 43% 3%;
--foreground: 253 31% 98%;
--muted: 253 7% 13%;
--muted-foreground: 253 13% 63%;
--popover: 253 43% 3%;
--popover-foreground: 253 31% 98%;
--card: 253 43% 4%;
--card-foreground: 253 31% 99%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--primary: 253 91% 58%;
--primary-foreground: 253 91% 98%;
--secondary: 253 7% 9%;
--secondary-foreground: 253 7% 69%;
--accent: 253 13% 14%;
--accent-foreground: 253 13% 74%;
--destructive: 339.2 90.36% 51.18%;
--destructive-foreground: 0 0% 100%;
--ring: 253 91% 58%;
}
}

View File

@@ -1,10 +1,7 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, height=device-height" />
@@ -21,8 +18,7 @@
}
</style>
<body data-sveltekit-preload-data="hover" class="dark">
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,4 +1,4 @@
import type { Handle } from "@sveltejs/kit";
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);

View File

@@ -1,20 +1,31 @@
import { messagesCache, channelsCache } from '$lib/stores/cache';
import type { Channel, Message } from '../types';
import { apiRequest } from './utils';
export async function getAllChannels() {
return await apiRequest<Channel[]>('/channel', 'get');
return await apiRequest<Channel[]>('/channel', 'get', undefined, (data) => {
data.forEach((channel) => {
channelsCache.set(channel.id, channel);
});
});
}
export async function getChannelById(channelId: number) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'get');
return await apiRequest<Channel>(`/channel/${channelId}`, 'get', undefined, (data) => {
channelsCache.set(data.id, data);
});
}
export async function createChannel(name: string) {
return await apiRequest<Channel>('/channel', 'post', { data: { name } });
return await apiRequest<Channel>('/channel', 'post', { data: { name } }, (data) => {
channelsCache.set(data.id, data);
});
}
export async function deleteChannel(channelId: number) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'delete');
return await apiRequest<Channel>(`/channel/${channelId}`, 'delete', undefined, (data) => {
channelsCache.remove(data.id);
});
}
export async function addUserToChannel(channelId: number, userId: number) {
@@ -26,5 +37,14 @@ export async function removeUserFromChannel(channelId: number, userId: number) {
}
export async function getMessagesByChannelId(channelId: number, beforeId?: number, limit?: number) {
return await apiRequest<Message[]>(`/channel/${channelId}/message${beforeId || limit ? '?' : ''}${beforeId ? `before=${beforeId}` : ''}${limit ? `&limit=${limit}` : ''}`, 'get');
return await apiRequest<Message[]>(
`/channel/${channelId}/message${beforeId || limit ? '?' : ''}${beforeId ? `before=${beforeId}` : ''}${limit ? `&limit=${limit}` : ''}`,
'get',
undefined,
(data) => {
data.forEach((message) => {
messagesCache.set(message.id, message);
});
}
);
}

View File

@@ -1,10 +1,20 @@
import { messagesCache } from '$lib/stores/cache';
import type { Message } from '../types';
import { apiRequest } from './utils';
export async function getMessageById(messageId: number) {
return await apiRequest<Message>(`/message/${messageId}`, 'get');
return await apiRequest<Message>(`/message/${messageId}`, 'get', undefined, (data) => {
messagesCache.set(data.id, data);
});
}
export async function createMessage(channelId: number, content: string) {
return await apiRequest<Message>('/message', 'post', { data: { channelId, content } });
return await apiRequest<Message>(
'/message',
'post',
{ data: { channelId, content } },
(data) => {
messagesCache.set(data.id, data);
}
);
}

View File

@@ -1,16 +1,28 @@
import { usersCache } from '$lib/stores/cache';
import type { Token, User } from '../types';
import { apiRequest } from './utils';
export async function getByToken(token: string | undefined | null) {
return await apiRequest<User>('/user/me', 'get', { token: token as string | undefined });
return await apiRequest<User>(
'/user/me',
'get',
{ token: token as string | undefined },
(data) => {
usersCache.set(data.id, data);
}
);
}
export async function getUserById(userId: number) {
return await apiRequest<User>(`/user/${userId}`, 'get');
return await apiRequest<User>(`/user/${userId}`, 'get', undefined, (data) => {
usersCache.set(data.id, data);
});
}
export async function getUserByUsername(username: string) {
return await apiRequest<User>(`/user/username/${username}`, 'get');
return await apiRequest<User>(`/user/username/${username}`, 'get', undefined, (data) => {
usersCache.set(data.id, data);
});
}
export async function loginUser(username: string, password: string) {

View File

@@ -1,14 +1,19 @@
import { API_URL } from "$lib/constants";
import { getUserToken } from "$lib/stores/user";
import type { ErrorResponse } from "$lib/types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import { API_URL } from '$lib/constants';
import { getUserToken } from '$lib/stores/user';
import type { ErrorResponse } from '$lib/types';
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
export async function apiRequest<T>(path: string, method: 'get' | 'post' | 'put' | 'delete', options?: AxiosRequestConfig & { token?: string }): Promise<T | ErrorResponse> {
export async function apiRequest<T>(
path: string,
method: 'get' | 'post' | 'put' | 'delete',
options?: AxiosRequestConfig & { token?: string },
cacheCallback?: (data: T) => void
): Promise<T | ErrorResponse> {
const url = API_URL + path;
console.log(`[API] ${method.toUpperCase()} ${url}`);
const token = options?.token || getUserToken();
options = {
@@ -16,16 +21,18 @@ export async function apiRequest<T>(path: string, method: 'get' | 'post' | 'put'
method,
headers: {
...options?.headers,
'Authorization': `Bearer ${token}`
Authorization: `Bearer ${token}`
},
validateStatus: () => true,
...options,
}
...options
};
const response = await axios.request(options);
if (response.status === 200)
return response.data as T;
else
return response.data as ErrorResponse;
if (response.status === 200) {
const data = response.data as T;
if (cacheCallback) cacheCallback(data);
return data;
} else return response.data as ErrorResponse;
}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Moon, Sun } from 'lucide-svelte';
import { Button } from './ui/button';
import { theme } from '$lib/stores/theme';
import { cn } from '$lib/utils';
let className = '';
export { className as class };
function switchTheme() {
$theme = $theme === 'light' ? 'dark' : 'light';
}
</script>
<Button variant="ghost" class={cn('h-10 w-10 p-0', className)} on:click={() => switchTheme()}>
<Sun class="hidden dark:block" />
<Moon class="block dark:hidden" />
</Button>

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = AvatarPrimitive.FallbackProps;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Fallback
class={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
class={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = AvatarPrimitive.ImageProps;
let className: $$Props["class"] = undefined;
export let src: $$Props["src"] = undefined;
export let alt: $$Props["alt"] = undefined;
let className: $$Props['class'] = undefined;
export let src: $$Props['src'] = undefined;
export let alt: $$Props['alt'] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Image
{src}
{alt}
class={cn("aspect-square h-full w-full", className)}
class={cn('aspect-square h-full w-full', className)}
{...$$restProps}
/>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = AvatarPrimitive.Props;
let className: $$Props["class"] = undefined;
export let delayMs: $$Props["delayMs"] = undefined;
let className: $$Props['class'] = undefined;
export let delayMs: $$Props['delayMs'] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Root
{delayMs}
class={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,6 +1,6 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
import Root from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';
export {
Root,
@@ -9,5 +9,5 @@ export {
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
Fallback as AvatarFallback
};

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { Button as ButtonPrimitive } from "bits-ui";
import { type Events, type Props, buttonVariants } from "./index.js";
import { cn } from "$lib/utils.js";
import { Button as ButtonPrimitive } from 'bits-ui';
import { type Events, type Props, buttonVariants } from './index.js';
import { cn } from '$lib/utils.js';
type $$Props = Props;
type $$Events = Events;
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let builders: $$Props["builders"] = [];
let className: $$Props['class'] = undefined;
export let variant: $$Props['variant'] = 'default';
export let size: $$Props['size'] = 'default';
export let builders: $$Props['builders'] = [];
export { className as class };
</script>

View File

@@ -1,34 +1,34 @@
import { type VariantProps, tv } from "tailwind-variants";
import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
import { type VariantProps, tv } from 'tailwind-variants';
import type { Button as ButtonPrimitive } from 'bits-ui';
import Root from './button.svelte';
const buttonVariants = tv({
base: "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: "default",
size: "default",
},
variant: 'default',
size: 'default'
}
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
@@ -45,5 +45,5 @@ export {
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
buttonVariants
};

View File

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

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn, flyAndScale } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = DropdownMenuPrimitive.ContentProps;
type $$Events = DropdownMenuPrimitive.ContentEvents;
let className: $$Props["class"] = undefined;
export let sideOffset: $$Props["sideOffset"] = 4;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined;
let className: $$Props['class'] = undefined;
export let sideOffset: $$Props['sideOffset'] = 4;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = undefined;
export { className as class };
</script>
@@ -17,7 +17,7 @@
{transitionConfig}
{sideOffset}
class={cn(
"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none",
'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none',
className
)}
{...$$restProps}

View File

@@ -1,21 +1,21 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
};
type $$Events = DropdownMenuPrimitive.ItemEvents;
let className: $$Props["class"] = undefined;
export let inset: $$Props["inset"] = undefined;
let className: $$Props['class'] = undefined;
export let inset: $$Props['inset'] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.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",
'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}

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = DropdownMenuPrimitive.LabelProps & {
inset?: boolean;
};
let className: $$Props["class"] = undefined;
export let inset: $$Props["inset"] = undefined;
let className: $$Props['class'] = undefined;
export let inset: $$Props['inset'] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.Label
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
class={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
type $$Props = DropdownMenuPrimitive.RadioGroupProps;
export let value: $$Props["value"] = undefined;
export let value: $$Props['value'] = undefined;
</script>
<DropdownMenuPrimitive.RadioGroup {...$$restProps} bind:value>

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import Circle from "lucide-svelte/icons/circle";
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import Circle from 'lucide-svelte/icons/circle';
import { cn } from '$lib/utils.js';
type $$Props = DropdownMenuPrimitive.RadioItemProps;
type $$Events = DropdownMenuPrimitive.RadioItemEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
let className: $$Props['class'] = undefined;
export let value: $$Props['value'];
export { className as class };
</script>
<DropdownMenuPrimitive.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",
'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}

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = DropdownMenuPrimitive.SeparatorProps;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.Separator
class={cn("-mx-1 my-1 h-px bg-muted", className)}
class={cn('-mx-1 my-1 h-px bg-muted', className)}
{...$$restProps}
/>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<span class={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...$$restProps}>
<span class={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...$$restProps}>
<slot />
</span>

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn, flyAndScale } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = DropdownMenuPrimitive.SubContentProps;
type $$Events = DropdownMenuPrimitive.SubContentEvents;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = {
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = {
x: -10,
y: 0,
y: 0
};
export { className as class };
</script>
@@ -18,7 +18,7 @@
{transition}
{transitionConfig}
class={cn(
"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none",
'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none',
className
)}
{...$$restProps}

View File

@@ -1,22 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRight from "lucide-svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
type $$Props = DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
};
type $$Events = DropdownMenuPrimitive.SubTriggerEvents;
let className: $$Props["class"] = undefined;
export let inset: $$Props["inset"] = undefined;
let className: $$Props['class'] = undefined;
export let inset: $$Props['inset'] = undefined;
export { className as class };
</script>
<DropdownMenuPrimitive.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",
'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}

View File

@@ -1,14 +1,14 @@
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import Content from "./dropdown-menu-content.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import RadioGroup from "./dropdown-menu-radio-group.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import Content from './dropdown-menu-content.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
@@ -44,5 +44,5 @@ export {
RadioGroup as DropdownMenuRadioGroup,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
CheckboxItem as DropdownMenuCheckboxItem,
CheckboxItem as DropdownMenuCheckboxItem
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import * as Button from "$lib/components/ui/button/index.js";
import * as Button from '$lib/components/ui/button/index.js';
type $$Props = Button.Props;
type $$Events = Button.Events;

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement>;
let className: string | undefined | null = undefined;
@@ -9,7 +9,7 @@
</script>
<FormPrimitive.Description
class={cn("text-sm text-muted-foreground", className)}
class={cn('text-sm text-muted-foreground', className)}
{...$$restProps}
let:descriptionAttrs
>

View File

@@ -1,25 +1,25 @@
<script lang="ts" context="module">
import type { FormPathLeaves, SuperForm } from "sveltekit-superforms";
import type { FormPathLeaves, SuperForm } from 'sveltekit-superforms';
type T = Record<string, unknown>;
type U = FormPathLeaves<T>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
import type { HTMLAttributes } from "svelte/elements";
import * as FormPrimitive from "formsnap";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.ElementFieldProps<T, U> & HTMLAttributes<HTMLElement>;
export let form: SuperForm<T>;
export let name: U;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<FormPrimitive.ElementField {form} {name} let:constraints let:errors let:tainted let:value>
<div class={cn("space-y-2", className)}>
<div class={cn('space-y-2', className)}>
<slot {constraints} {errors} {tainted} {value} />
</div>
</FormPrimitive.ElementField>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.FieldErrorsProps & {
errorClasses?: string | undefined | null;
};
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
export let errorClasses: $$Props["class"] = undefined;
export let errorClasses: $$Props['class'] = undefined;
</script>
<FormPrimitive.FieldErrors
class={cn("text-sm font-medium text-destructive", className)}
class={cn('text-sm font-medium text-destructive', className)}
{...$$restProps}
let:errors
let:fieldErrorsAttrs

View File

@@ -1,25 +1,25 @@
<script lang="ts" context="module">
import type { FormPath, SuperForm } from "sveltekit-superforms";
import type { FormPath, SuperForm } from 'sveltekit-superforms';
type T = Record<string, unknown>;
type U = FormPath<T>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import type { HTMLAttributes } from "svelte/elements";
import * as FormPrimitive from "formsnap";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.FieldProps<T, U> & HTMLAttributes<HTMLElement>;
export let form: SuperForm<T>;
export let name: U;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<FormPrimitive.Field {form} {name} let:constraints let:errors let:tainted let:value>
<div class={cn("space-y-2", className)}>
<div class={cn('space-y-2', className)}>
<slot {constraints} {errors} {tainted} {value} />
</div>
</FormPrimitive.Field>

View File

@@ -1,19 +1,19 @@
<script lang="ts" context="module">
import type { FormPath, SuperForm } from "sveltekit-superforms";
import type { FormPath, SuperForm } from 'sveltekit-superforms';
type T = Record<string, unknown>;
type U = FormPath<T>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from "formsnap";
import { cn } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.FieldsetProps<T, U>;
export let form: SuperForm<T>;
export let name: U;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
@@ -24,7 +24,7 @@
let:errors
let:tainted
let:value
class={cn("space-y-2", className)}
class={cn('space-y-2', className)}
>
<slot {constraints} {errors} {tainted} {value} />
</FormPrimitive.Fieldset>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import type { Label as LabelPrimitive } from "bits-ui";
import { getFormControl } from "formsnap";
import { cn } from "$lib/utils.js";
import { Label } from "$lib/components/ui/label/index.js";
import type { Label as LabelPrimitive } from 'bits-ui';
import { getFormControl } from 'formsnap';
import { cn } from '$lib/utils.js';
import { Label } from '$lib/components/ui/label/index.js';
type $$Props = LabelPrimitive.Props;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
const { labelAttrs } = getFormControl();
</script>
<Label {...$labelAttrs} class={cn("data-[fs-error]:text-destructive", className)} {...$$restProps}>
<Label {...$labelAttrs} class={cn('data-[fs-error]:text-destructive', className)} {...$$restProps}>
<slot {labelAttrs} />
</Label>

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn } from "$lib/utils.js";
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.LegendProps;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<FormPrimitive.Legend
{...$$restProps}
class={cn("text-sm font-medium leading-none data-[fs-error]:text-destructive", className)}
class={cn('text-sm font-medium leading-none data-[fs-error]:text-destructive', className)}
let:legendAttrs
>
<slot {legendAttrs} />

View File

@@ -1,12 +1,12 @@
import * as FormPrimitive from "formsnap";
import Description from "./form-description.svelte";
import Label from "./form-label.svelte";
import FieldErrors from "./form-field-errors.svelte";
import Field from "./form-field.svelte";
import Fieldset from "./form-fieldset.svelte";
import Legend from "./form-legend.svelte";
import ElementField from "./form-element-field.svelte";
import Button from "./form-button.svelte";
import * as FormPrimitive from 'formsnap';
import Description from './form-description.svelte';
import Label from './form-label.svelte';
import FieldErrors from './form-field-errors.svelte';
import Field from './form-field.svelte';
import Fieldset from './form-fieldset.svelte';
import Legend from './form-legend.svelte';
import ElementField from './form-element-field.svelte';
import Button from './form-button.svelte';
const Control = FormPrimitive.Control;
@@ -29,5 +29,5 @@ export {
Fieldset as FormFieldset,
Legend as FormLegend,
ElementField as FormElementField,
Button as FormButton,
Button as FormButton
};

View File

@@ -1,4 +1,4 @@
import Root from "./input.svelte";
import Root from './input.svelte';
export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement;
@@ -25,5 +25,5 @@ export type InputEvents = {
export {
Root,
//
Root as Input,
Root as Input
};

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import type { InputEvents } from "./index.js";
import { cn } from "$lib/utils.js";
import type { HTMLInputAttributes } from 'svelte/elements';
import type { InputEvents } from './index.js';
import { cn } from '$lib/utils.js';
type $$Props = HTMLInputAttributes;
type $$Events = InputEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = undefined;
let className: $$Props['class'] = undefined;
export let value: $$Props['value'] = undefined;
export { className as class };
// Workaround for https://github.com/sveltejs/svelte/issues/9305
// Fixed in Svelte 5, but not backported to 4.x.
export let readonly: $$Props["readonly"] = undefined;
export let readonly: $$Props['readonly'] = undefined;
</script>
<input
class={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value

View File

@@ -1,7 +1,7 @@
import Root from "./label.svelte";
import Root from './label.svelte';
export {
Root,
//
Root as Label,
Root as Label
};

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = LabelPrimitive.Props;
type $$Events = LabelPrimitive.Events;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<LabelPrimitive.Root
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...$$restProps}

View File

@@ -1,5 +1,5 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from './popover-content.svelte';
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
@@ -13,5 +13,5 @@ export {
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Close as PopoverClose
};

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn, flyAndScale } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn, flyAndScale } from '$lib/utils.js';
type $$Props = PopoverPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined;
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = undefined;
export { className as class };
</script>
@@ -13,7 +13,7 @@
{transition}
{transitionConfig}
class={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
className
)}
{...$$restProps}

View File

@@ -1,10 +1,10 @@
import Scrollbar from "./scroll-area-scrollbar.svelte";
import Root from "./scroll-area.svelte";
import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar,
Scrollbar as ScrollAreaScrollbar
};

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = ScrollAreaPrimitive.ScrollbarProps & {
orientation?: "vertical" | "horizontal";
orientation?: 'vertical' | 'horizontal';
};
let className: $$Props["class"] = undefined;
export let orientation: $$Props["orientation"] = "vertical";
let className: $$Props['class'] = undefined;
export let orientation: $$Props['orientation'] = 'vertical';
export { className as class };
</script>
<ScrollAreaPrimitive.Scrollbar
{orientation}
class={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-px",
orientation === "horizontal" && "h-2.5 w-full border-t border-t-transparent p-px",
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 w-full border-t border-t-transparent p-px',
className
)}
>
<slot />
<ScrollAreaPrimitive.Thumb
class={cn("relative rounded-full bg-border", orientation === "vertical" && "flex-1")}
class={cn('relative rounded-full bg-border', orientation === 'vertical' && 'flex-1')}
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -22,32 +22,34 @@
let viewport: HTMLDivElement;
const scrollBottom = (node: HTMLDivElement, top: number) => {
const scroll = () =>
node.scroll({
top,
behavior: 'instant'
});
scroll();
return { update: scroll };
const scrollTo = (node: HTMLDivElement, top: number, behavior: ScrollBehavior) => {
const scrollNode = () => {
node.scrollTo({ top: top, behavior });
node.scrollTop = top;
};
scrollNode();
};
onMount(() => {
if (scrollToBottom) {
scrollBottom(viewport, viewport.scrollHeight);
scrollTo(viewport, viewport.scrollHeight, 'instant');
}
});
export const scroll = (anchor: 'top' | 'bottom') => {
export const scroll = (anchor: 'top' | 'bottom', behavior: ScrollBehavior = 'smooth') => {
if (anchor === 'bottom') {
scrollBottom(viewport, viewport.scrollHeight);
scrollTo(viewport, viewport.scrollHeight, behavior);
}
if (anchor === 'top') {
scrollBottom(viewport, 0);
scrollTo(viewport, 0, behavior);
}
};
export const getScrollPercent = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
return scrollTop / (scrollHeight - clientHeight);
};
</script>
<ScrollAreaPrimitive.Root {...$$restProps} class={cn('relative overflow-hidden', className)}>

View File

@@ -1,7 +1,7 @@
import Root from "./separator.svelte";
import Root from './separator.svelte';
export {
Root,
//
Root as Separator,
Root as Separator
};

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = SeparatorPrimitive.Props;
let className: $$Props["class"] = undefined;
export let orientation: $$Props["orientation"] = "horizontal";
export let decorative: $$Props["decorative"] = undefined;
let className: $$Props['class'] = undefined;
export let orientation: $$Props['orientation'] = 'horizontal';
export let decorative: $$Props['decorative'] = undefined;
export { className as class };
</script>
<SeparatorPrimitive.Root
class={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{orientation}

View File

@@ -1,7 +1,7 @@
import Root from "./skeleton.svelte";
import Root from './skeleton.svelte';
export {
Root,
//
Root as Skeleton,
Root as Skeleton
};

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div class={cn("animate-pulse rounded-md bg-muted", className)} {...$$restProps}></div>
<div class={cn('animate-pulse rounded-md bg-muted', className)} {...$$restProps}></div>

View File

@@ -1 +1 @@
export { default as Toaster } from "./sonner.svelte";
export { default as Toaster } from './sonner.svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
import { mode } from 'mode-watcher';
type $$Props = SonnerProps;
</script>
@@ -10,11 +10,11 @@
class="toaster group"
toastOptions={{
classes: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
}
}}
{...$$restProps}
/>

View File

@@ -1,7 +1,7 @@
import Root from "./switch.svelte";
import Root from './switch.svelte';
export {
Root,
//
Root as Switch,
Root as Switch
};

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
type $$Props = SwitchPrimitive.Props;
type $$Events = SwitchPrimitive.Events;
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = undefined;
let className: $$Props['class'] = undefined;
export let checked: $$Props['checked'] = undefined;
export { className as class };
</script>
<SwitchPrimitive.Root
bind:checked
class={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
'peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...$$restProps}
@@ -22,7 +22,7 @@
>
<SwitchPrimitive.Thumb
class={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>

View File

@@ -1,4 +1,4 @@
import Root from "./textarea.svelte";
import Root from './textarea.svelte';
type FormTextareaEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLTextAreaElement;
@@ -24,5 +24,5 @@ export {
//
Root as Textarea,
type TextareaEvents,
type FormTextareaEvent,
type FormTextareaEvent
};

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import type { HTMLTextareaAttributes } from "svelte/elements";
import type { TextareaEvents } from "./index.js";
import { cn } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from 'svelte/elements';
import type { TextareaEvents } from './index.js';
import { cn } from '$lib/utils.js';
type $$Props = HTMLTextareaAttributes;
type $$Events = TextareaEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = undefined;
let className: $$Props['class'] = undefined;
export let value: $$Props['value'] = undefined;
export { className as class };
// Workaround for https://github.com/sveltejs/svelte/issues/9305
// Fixed in Svelte 5, but not backported to 4.x.
export let readonly: $$Props["readonly"] = undefined;
export let readonly: $$Props['readonly'] = undefined;
</script>
<textarea
class={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value

View File

@@ -1 +1,2 @@
export const API_URL = 'http://localhost:1234';
export const BASE_API_URL = 'localhost:1234';
export const API_URL = `http://${BASE_API_URL}`;

39
src/lib/event.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface Listener<D> {
(event: D): unknown;
}
export interface Disposable {
dispose(): void;
}
export class EventEmitter<E, D> {
private listeners: Map<E, Listener<D>[]> = new Map();
on = (event: E, listener: Listener<D>): Disposable => {
if (!this.listeners.has(event)) this.listeners.set(event, []);
this.listeners.get(event)?.push(listener);
return {
dispose: () => this.off(event, listener)
};
};
off = (event: E, listener: Listener<D>) => {
if (!this.listeners.has(event)) return;
const listeners = this.listeners.get(event)?.filter((l) => l !== listener);
this.listeners.set(event, listeners || []);
};
emit = (event: E, data: D) => {
if (!this.listeners.has(event)) return;
this.listeners.get(event)?.forEach((listener) => setTimeout(() => listener(data), 0));
};
pipe = (event: E, te: EventEmitter<E, D>): Disposable => {
return this.on(event, (e) => te.emit(event, e));
};
}

View File

@@ -1,41 +0,0 @@
import { getChannelById } from "$lib/api/channel";
import { isErrorResponse, type Channel } from "$lib/types";
import { get, writable, type Writable } from "svelte/store";
const channelsCache: Writable<Map<number, Channel>> = writable(new Map<number, Channel>());
const runningCaches = new Set();
export function addChannelToCache(channel: Channel) {
channelsCache.update((channels) => channels.set(channel.id, channel));
}
export async function getCachedChannel(channelId: number): Promise<Channel | null> {
const cached = get(channelsCache).get(channelId);
if (cached)
return cached;
if (runningCaches.has(channelId))
return await new Promise((resolve) => {
channelsCache.subscribe((channels) => {
const channel = channels.get(channelId);
if (channel) {
runningCaches.delete(channelId);
resolve(channel);
}
});
});
runningCaches.add(channelId);
const response = await getChannelById(channelId);
if (isErrorResponse(response))
return null;
const channel = response as Channel;
addChannelToCache(channel);
return channel;
}

23
src/lib/stores/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
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 { Cache } from './utils';
export const usersCache: Cache<number, User> = new Cache(async (id) => {
const response = await getUserById(id);
if (isErrorResponse(response)) return null;
return response;
});
export const messagesCache: Cache<number, Message> = new Cache(async (id) => {
const response = await getMessageById(id);
if (isErrorResponse(response)) return null;
return response;
});
export const channelsCache: Cache<number, Channel> = new Cache(async (id) => {
const response = await getChannelById(id);
if (isErrorResponse(response)) return null;
return response;
});

View File

@@ -1,42 +0,0 @@
import { getMessageById } from "$lib/api/message";
import { isErrorResponse, type Message } from "$lib/types";
import { get, writable, type Writable } from "svelte/store";
const messagesCache: Writable<Map<number, Message>> = writable(new Map<number, Message>());
const runningCaches = new Set();
export function addMessageToCache(message: Message) {
messagesCache.update((messages) => messages.set(message.id, message));
}
export async function getCachedMessage(messageId: number): Promise<Message | null> {
const cached = get(messagesCache).get(messageId);
if (cached)
return cached;
if (runningCaches.has(messageId))
return await new Promise((resolve) => {
messagesCache.subscribe((users) => {
const user = users.get(messageId);
if (user) {
runningCaches.delete(messageId);
resolve(user);
}
});
});
runningCaches.add(messageId);
const response = await getMessageById(messageId);
if (isErrorResponse(response))
return null;
const message = response as Message;
addMessageToCache(message);
return message;
}

View File

@@ -1,47 +0,0 @@
import { getUserById } from "$lib/api/user";
import { isErrorResponse, type User } from "$lib/types";
import { get, writable, type Writable } from "svelte/store";
const usersCache: Writable<Map<number, User>> = writable(new Map<number, User>());
const runningCaches = new Set();
usersCache.subscribe((users) => {
console.log(`Cached users: ${JSON.stringify([...users.values()])}`);
})
export function addUserToCache(user: User) {
usersCache.update((users) => users.set(user.id, user));
}
export async function getCachedUser(userId: number): Promise<User | null> {
const cachedUser = get(usersCache).get(userId);
if (cachedUser)
return cachedUser;
if (runningCaches.has(userId)) {
return await new Promise((resolve) => {
usersCache.subscribe((users) => {
console.log(`subsribed called`);
const user = users.get(userId);
if (user) {
runningCaches.delete(userId);
resolve(user);
}
});
});
}
runningCaches.add(userId);
const response = await getUserById(userId);
if (isErrorResponse(response))
return null;
const user = response as User;
addUserToCache(user);
return user;
}

57
src/lib/stores/cache/utils.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
import { get, writable, type Writable } from 'svelte/store';
export class Cache<I, T> {
private data: Writable<Map<I, T>> = writable(new Map<I, T>());
private runningCaches: Set<I> = new Set();
private resolver: (data: I) => Promise<T | null>;
constructor(resolver: (data: I) => Promise<T | null>) {
this.resolver = resolver;
}
async get(key: I): Promise<T | null> {
const cached = get(this.data).get(key);
if (cached) {
console.log(`[Cache] Found in cache: `, cached);
return cached;
}
if (this.runningCaches.has(key)) {
return new Promise((resolve) => {
this.data.subscribe((data) => {
const value = data.get(key);
if (value) {
resolve(value);
}
});
});
}
this.runningCaches.add(key);
const data = await this.resolver(key);
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);
this.data.update((data) => data.set(key, value));
}
remove(key: I) {
console.log(`[Cache] Removed from cache: `, key);
this.data.update((data) => {
data.delete(key);
return data;
});
}
}

19
src/lib/stores/theme.ts Normal file
View File

@@ -0,0 +1,19 @@
import { browser } from '$app/environment';
import { persisted } from 'svelte-persisted-store';
import type { Writable } from 'svelte/store';
function initialTheme() {
if (!browser) return 'light';
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
return 'light';
}
export const theme: Writable<'light' | 'dark'> = persisted('theme', initialTheme());
theme.subscribe((theme) => {
if (!browser) return;
if (theme === 'light') document.documentElement.classList.remove('dark');
else document.documentElement.classList.add('dark');
});

View File

@@ -1,21 +1,15 @@
import { derived, get } from "svelte/store";
import { isErrorResponse, type User } from "../types";
import { getByToken } from "$lib/api/user";
import { persisted } from "svelte-persisted-store";
import { derived, get } from 'svelte/store';
import { isErrorResponse, type User } from '../types';
import { getByToken } from '$lib/api/user';
import { persisted } from 'svelte-persisted-store';
export const token = persisted<string | null>('token', null);
token.subscribe((token) => {
console.log(`updated token: ${JSON.stringify(token)}`);
})
export const user = derived<typeof token, User | null>(token, ($token, set) => {
getByToken($token).then((response) => {
if (isErrorResponse(response))
set(null);
else
set(response);
})
if (isErrorResponse(response)) set(null);
else set(response);
});
});
export function getUserToken(): string | null {

View File

@@ -0,0 +1,98 @@
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';
export type WebSocketMessageType =
| 'createMessage'
| 'updateChannel'
| 'createChannel'
| 'deleteChannel'
| 'connect'
| 'disconnect'
| 'any';
export type WebSocketMessageData =
| Message
| Channel
| { id: number }
| null
| {
type: WebSocketMessageType;
};
export type WebsoketMessage = {
type: WebSocketMessageType;
data: WebSocketMessageData;
};
export const appWebsocket = new EventEmitter<WebSocketMessageType, WebSocketMessageData>();
appWebsocket.on('any', (data) => {
console.log(`[WS] Recieved message: `, data);
});
function updateCache(type: WebSocketMessageType, data: WebSocketMessageData) {
switch (type) {
case 'createMessage':
messagesCache.set((data as Message).id, data as Message);
break;
case 'updateChannel':
channelsCache.set((data as Channel).id, data as Channel);
break;
case 'createChannel':
channelsCache.set((data as Channel).id, data as Channel);
break;
case 'deleteChannel':
channelsCache.remove((data as { id: number }).id);
break;
default:
break;
}
}
const connect = (token: string) => {
const websocket = new WebSocket(`ws://${BASE_API_URL}/ws/${token}`);
websocket.onopen = () => {
appWebsocket.emit('connect', null);
appWebsocket.emit('any', { type: 'connect' });
};
websocket.onmessage = (event) => {
const message: WebsoketMessage = JSON.parse(event.data);
updateCache(message.type, message.data);
appWebsocket.emit(message.type, message.data as WebSocketMessageData);
appWebsocket.emit('any', message);
};
websocket.onclose = () => {
appWebsocket.emit('disconnect', null);
appWebsocket.emit('any', { type: 'disconnect' });
setTimeout(() => {
const token = get(tokenStore);
if (token) connect(token);
}, 500);
};
return websocket;
};
const socket = derived<typeof tokenStore, WebSocket | null>(
tokenStore,
($token, set) => {
if ($token) {
set(connect($token));
} else {
set(null);
}
},
null
);
socket.subscribe((socket) => {
console.log(`[WS] Connected: `, socket);
});

View File

@@ -1,9 +1,9 @@
export type ErrorResponse = {
error: string;
}
};
export function isErrorResponse(data: unknown): data is ErrorResponse {
return ((data as ErrorResponse).error !== undefined);
return (data as ErrorResponse).error !== undefined;
}
export type Token = {
@@ -11,7 +11,7 @@ export type Token = {
userId: number;
createdAt: string;
expiresAt: string;
}
};
export type User = {
id: number;

View File

@@ -1,7 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cubicOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -19,7 +19,7 @@ export const flyAndScale = (
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
const transform = style.transform === 'none' ? '' : style.transform;
const scaleConversion = (
valueA: number,
@@ -35,13 +35,11 @@ export const flyAndScale = (
return valueB;
};
const styleToString = (
style: Record<string, number | string | undefined>
): string => {
const styleToString = (style: Record<string, number | string | undefined>): string => {
return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`;
}, "");
}, '');
};
return {

View File

@@ -1,2 +1,4 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => { return /^\d+$/.test(param); };
export const match: ParamMatcher = (param) => {
return /^\d+$/.test(param);
};

View File

@@ -3,20 +3,16 @@ import type { LayoutServerLoad } from './$types';
import { getByToken } from '$lib/api/user';
import { isErrorResponse } from '$lib/types';
export const prerender = false;
export const ssr = false;
// export const prerender = false;
// export const ssr = false;
export const load = (async ({ cookies }) => {
const token = cookies.get('token');
if (!token)
return redirect(302, '/logout');
if (!token) return redirect(302, '/logout');
const user = await getByToken(token)
if (isErrorResponse(user))
return redirect(302, '/logout');
console.log(`User: ${JSON.stringify(user)}`)
const user = await getByToken(token);
if (isErrorResponse(user)) return redirect(302, '/logout');
return { token, user };
}) satisfies LayoutServerLoad;

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import type { LayoutData } from './$types';
import { token, user } from '$lib/stores/user';
import { addUserToCache } from '$lib/stores/cache/users';
import { token } from '$lib/stores/user';
import { usersCache } from '$lib/stores/cache';
import { appWebsocket } from '$lib/stores/websocket';
export let data: LayoutData;
console.log(`loading`);
console.log(data);
token.update(() => data.token);
$token = data.token;
addUserToCache(data.user);
const user = data.user;
usersCache.set(user.id, user);
</script>
<slot />

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import { createMessage } from '$lib/api/message';
import * as Avatar from '$lib/components/ui/avatar';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { isErrorResponse, type Channel, type Message as MessageType } from '$lib/types';
import { type Channel, type Message, type Message as MessageType } from '$lib/types';
import { afterUpdate } from 'svelte';
import MessageArea from './message-area.svelte';
import Message from './message.svelte';
import TextField from './text-field.svelte';
export let channel: Channel | null = null;
@@ -12,34 +11,40 @@
let messageArea: MessageArea;
const sendMessage = (message: string) => {
const sendMessage = (content: string) => {
if (!channel) return;
createMessage(channel.id, message).then((message) => {
if (!isErrorResponse(message)) {
messages = [...messages, message];
messageArea.scroll('bottom');
}
});
createMessage(channel.id, content);
};
export function updateMessages(newMessages: Message[]) {
messages = newMessages;
}
afterUpdate(() => {
if (!messageArea) return;
if (messageArea.getScrollPercent() > 0.95) messageArea.scroll('bottom', 'smooth');
});
</script>
<div class="flex h-screen w-[95%] flex-col contain-strict">
<div class="contents">
<div class="flex items-center space-x-4">
<div class="flex h-screen w-full flex-col items-center contain-strict">
<div class="w-full">
<div class="flex items-center justify-center space-x-4 bg-secondary p-4">
<Avatar.Root class="h-12 w-12">
<Avatar.Image src="/default-avatar.png" />
<Avatar.Fallback>{channel?.name[0].toUpperCase() || ''}</Avatar.Fallback>
</Avatar.Root>
<span class="text-xl font-bold">{channel?.name || ''}</span>
<span class="text-3xl font-bold">{channel?.name || ''}</span>
</div>
</div>
<div class="contents flex-grow">
<div class="flex h-screen w-[95%] flex-col contain-strict">
<div class="z-[10000] contents flex-grow">
<MessageArea {messages} bind:this={messageArea} />
</div>
<div class="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} />
</div>
</div>
</div>

View File

@@ -7,21 +7,26 @@
let scrollArea: ScrollArea;
messages = [...messages].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
$: messages = [...messages].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
export function scroll(anchor: 'top' | 'bottom') {
if (scrollArea) {
scrollArea.scroll(anchor);
export function scroll(anchor: 'top' | 'bottom', behavior: ScrollBehavior = 'smooth') {
scrollArea.scroll(anchor, behavior);
}
export function getScrollPercent() {
return scrollArea.getScrollPercent();
}
</script>
<div class="h-full w-full overflow-y-hidden">
<ScrollArea class="h-full w-full" scrollToBottom={true} bind:this={scrollArea}>
<div class="mx-4 flex flex-col gap-3">
<div class="mx-4 flex flex-col gap-3 pt-4">
{#each messages as message}
<Message {message} />
{/each}
</div>
<!-- <div class="h-[6rem] invisible" /> -->
</ScrollArea>
</div>

View File

@@ -3,15 +3,14 @@
import { user } from '$lib/stores/user';
import { cn } from '$lib/utils';
import * as Avatar from '$lib/components/ui/avatar';
import { getCachedUser } from '$lib/stores/cache/users';
import { usersCache } from '$lib/stores/cache';
import { writable, type Writable } from 'svelte/store';
import { Skeleton } from '$lib/components/ui/skeleton';
export let message: Message;
let sender: Writable<User | null> = writable(null);
getCachedUser(message.authorId).then((user) => ($sender = user));
usersCache.get(message.authorId).then((user) => ($sender = user));
$: username = (isSelf ? $user?.username : $sender?.username) || 'N';
$: isSelf = $user?.id === message.authorId;
@@ -33,7 +32,7 @@
<span class="whitespace-pre-line break-words break-all text-left text-xl font-bold">
{message.content}
</span>
<span class={cn('text-md', timestampPosition)}
<span class={cn('text-sm', timestampPosition)}
>{new Date(message.createdAt).toLocaleString()}
</span>
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { writable } from 'svelte/store';
import { Send } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { browser } from '$app/environment';
@@ -15,20 +14,11 @@
const text = event.clipboardData?.getData('text/plain');
if (text && browser) {
// if (window.getSelection) {
// var selObj = window.getSelection();
// var selRange = selObj?.getRangeAt(0);
// selRange?.deleteContents();
// selRange?.insertNode(document.createTextNode(text));
// selObj?.collapseToEnd();
// }
document.execCommand('insertText', false, text);
}
}
function onSendClick() {
console.log(content);
if (content) {
onSend(content);
content = '';
@@ -36,7 +26,9 @@
}
</script>
<div class="flex h-full items-center gap-2 rounded-3xl bg-secondary p-2">
<div
class="flex h-full items-center gap-2 rounded-3xl bg-secondary p-2 shadow-md shadow-secondary-foreground dark:shadow-none"
>
<ScrollArea class="h-full w-full">
<div
contenteditable="true"

View File

@@ -1,36 +1,48 @@
<script lang="ts">
import { getMessageById } from '$lib/api/message';
import * as Avatar from '$lib/components/ui/avatar';
import { isErrorResponse, type Channel, type Message } from '$lib/types';
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;
if (channel.lastMessageId) {
getMessageById(channel.lastMessageId).then((message) => {
if (!isErrorResponse(message)) lastMessage = message;
});
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 = () => {};
$: className = selected ? 'bg-accent' : 'hover:bg-secondary';
$: 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', className)}>
<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-auto">
<span class="overflow-hidden text-ellipsis text-left text-xl font-bold">{channel.name}</span
>
<span class="overflow-hidden text-ellipsis text-left text-sm"
>{lastMessage?.content || ''}</span
>
<div class="flex flex-col overflow-hidden">
<span class="overflow-hidden text-ellipsis text-left text-xl font-bold">
{channel.name}
</span>
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-left text-sm">
{#if lastMessage}
{`${lastMessageAuthor?.username}: ${lastMessage.content}`}
{/if}
</span>
</div>
</button>

View File

@@ -16,11 +16,13 @@
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 w-full">
<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">
@@ -40,4 +42,12 @@
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<div class="flex-grow pt-1 text-center text-2xl">
{$user?.username}
</div>
<div>
<ThemeSwitch />
</div>
</div>

View File

@@ -1,11 +1,9 @@
<script lang="ts">
import { ScrollArea, ScrollAreaScrollbar } from '$lib/components/ui/scroll-area';
const tags = Array.from({ length: 50 }).map((_, i, a) => `v1.2.0-beta.${a.length - i}`);
import { ScrollArea } from '$lib/components/ui/scroll-area';
</script>
<div>
<div>
<div class="light:shadow-secondary-foreground flex h-full w-full flex-col bg-secondary shadow-lg">
<div class="p-0">
<slot name="header"></slot>
</div>
<div>

View File

@@ -8,14 +8,15 @@
import type { Icon } from 'lucide-svelte';
import Settings from 'lucide-svelte/icons/settings';
import LogOut from 'lucide-svelte/icons/log-out';
import type { ComponentType } from 'svelte';
import { onDestroy, onMount, type ComponentType } from 'svelte';
import type { MenuItem } from './(components)/sidebar-header.svelte';
import ChannelList from './(components)/channel-list.svelte';
import { addChannelToCache } from '$lib/stores/cache/channels';
import { appWebsocket, type WebSocketMessageType } from '$lib/stores/websocket';
import type { Channel } from '$lib/types';
export let data: LayoutData;
for (const channel of data.channels) addChannelToCache(channel);
let channels = data.channels;
let channelList: ChannelList | undefined;
@@ -44,6 +45,51 @@
];
$: channelId = parseInt($page.params.channel_id);
function handleChannelCreated(channel: unknown) {
const typedChannel = channel as Channel;
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;
}
}
}
function handleChannelDeleted(channel_id: unknown) {
const id = (channel_id as { id: number }).id;
if (!channelList) return;
channels = channels.filter((c) => c.id != id);
}
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);
});
</script>
<svelte:window on:keyup={onKeyUp} />
@@ -56,11 +102,7 @@
</div>
<div slot="channels">
<ChannelList
channels={data.channels}
defaultSelected={channelId}
bind:this={channelList}
/>
<ChannelList {channels} defaultSelected={channelId} bind:this={channelList} />
</div>
</Sidebar>
</div>

View File

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

View File

@@ -2,21 +2,40 @@
import type { PageData } from './$types';
import { page } from '$app/stores';
import ChannelArea from '../(components)/(channel)/channel-area.svelte';
import { getCachedChannel } from '$lib/stores/cache/channels';
import type { Channel } from '$lib/types';
import { addMessageToCache } from '$lib/stores/cache/messages';
import { channelsCache } from '$lib/stores/cache';
import type { Channel, Message } from '$lib/types';
import { appWebsocket } from '$lib/stores/websocket';
import { onDestroy, onMount } from 'svelte';
export let data: PageData;
let channel: Channel | null = null;
$: channelId = parseInt($page.params.channel_id);
$: getCachedChannel(channelId).then((c) => (channel = c));
$: channelsCache.get(channelId).then((c) => (channel = c));
for (const message of data.messages)
addMessageToCache(message);
const messages = data.messages;
let channelArea: ChannelArea;
function handleCreateMessage(message: unknown) {
const typedMessage = message as Message;
if (!channel) return;
if (typedMessage.channelId != channel.id) return;
messages.push(typedMessage);
channelArea?.updateMessages(messages);
}
onMount(() => {
appWebsocket.on('createMessage', handleCreateMessage);
});
onDestroy(() => {
appWebsocket.off('createMessage', handleCreateMessage);
});
</script>
<div class="flex h-screen w-full items-center justify-center">
<ChannelArea {channel} messages={data.messages} />
<ChannelArea {channel} {messages} bind:this={channelArea} />
</div>

View File

@@ -1,5 +1,5 @@
import { getMessagesByChannelId } from '$lib/api/channel';
import { getCachedChannel } from '$lib/stores/cache/channels';
import { channelsCache } from '$lib/stores/cache';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { isErrorResponse } from '$lib/types';
@@ -9,15 +9,13 @@ export const ssr = false;
export const load = (async ({ params, parent }) => {
await parent();
const channelId = parseInt(params.channel_id)
const channel = await getCachedChannel(channelId);
if (!channel)
return redirect(302, '/channels');
const channelId = parseInt(params.channel_id);
const channel = await channelsCache.get(channelId);
if (!channel) return redirect(302, '/channels');
const messages = await getMessagesByChannelId(channel.id, channel.lastMessageId);
if (isErrorResponse(messages))
return redirect(302, '/channels');
if (isErrorResponse(messages)) return redirect(302, '/channels');
return { messages };
}) satisfies PageLoad;

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import ThemeSwitch from '$lib/components/theme-switch.svelte';
import { Fan } from 'lucide-svelte';
</script>
<div class="flex h-full w-full items-center justify-center">
<ThemeSwitch class="absolute right-4 top-4" />
<div class="flex h-full w-full flex-col items-center justify-center gap-8">
<Fan class="h-32 w-32" />
<slot />
</div>
</div>

View File

@@ -1,15 +1,13 @@
import type { PageServerLoad } from './$types';
import { zod } from "sveltekit-superforms/adapters";
import { zod } from 'sveltekit-superforms/adapters';
import { loginFormSchema } from './login-form.svelte';
import { superValidate } from "sveltekit-superforms";
import { type Actions, fail, redirect } from "@sveltejs/kit";
import { superValidate } from 'sveltekit-superforms';
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { loginUser } from '$lib/api/user';
import { isErrorResponse } from '$lib/types';
export const load = (async ({ cookies }) => {
if (cookies.get('token'))
throw redirect(302, '/channels');
if (cookies.get('token')) throw redirect(302, '/channels');
return { form: await superValidate(zod(loginFormSchema)) };
}) satisfies PageServerLoad;
@@ -18,8 +16,7 @@ export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod(loginFormSchema));
if (!form.valid)
return fail(400, { form });
if (!form.valid) return fail(400, { form });
const response = await loginUser(form.data.username, form.data.password);
@@ -31,5 +28,5 @@ export const actions: Actions = {
event.cookies.set('token', response.token, { path: '/' });
return { form, token: response };
},
}
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import type { PageData } from './$types';
import LoginForm from './login-form.svelte';
export let data: PageData;
</script>
<div class="w-[20%] min-w-[300px] space-y-4 rounded-lg bg-secondary p-4">
<h1 class="text-center text-4xl font-bold">Login</h1>
<LoginForm data={data.form}></LoginForm>
</div>

View File

@@ -1,7 +1,7 @@
import type { PageServerLoad } from './$types';
import { superValidate } from "sveltekit-superforms";
import { type Actions, fail } from "@sveltejs/kit";
import { zod } from "sveltekit-superforms/adapters";
import { superValidate } from 'sveltekit-superforms';
import { type Actions, fail } from '@sveltejs/kit';
import { zod } from 'sveltekit-superforms/adapters';
import { registerFormSchema } from './register-form.svelte';
import { registerUser } from '$lib/api/user';
import { isErrorResponse } from '$lib/types';
@@ -14,8 +14,7 @@ export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod(registerFormSchema));
if (!form.valid)
return fail(400, { form });
if (!form.valid) return fail(400, { form });
const response = await registerUser(form.data.username, form.data.password);
@@ -26,4 +25,4 @@ export const actions: Actions = {
return { form, success: true };
}
}
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import type { PageData } from './$types';
import RegisterForm from './register-form.svelte';
export let data: PageData;
</script>
<div class="w-[20%] min-w-[300px] space-y-4 rounded-lg bg-secondary p-4">
<h1 class="text-center text-4xl font-bold">Register</h1>
<RegisterForm data={data.form}></RegisterForm>
</div>

View File

@@ -25,9 +25,7 @@
import { Button } from '$lib/components/ui/button';
import * as Form from '$lib/components/ui/form';
import { superForm, type SuperValidated } from 'sveltekit-superforms';
import { Label } from '$lib/components/ui/label';
import { zodClient } from 'sveltekit-superforms/adapters';
import { goto } from '$app/navigation';
export let data: SuperValidated<RegisterFormSchema>;
export let loginUrl: string = '/login';

View File

@@ -1,5 +1,7 @@
<script>
import '../app.css';
import { theme } from '$lib/stores/theme';
</script>
<div class="h-full w-full">

View File

@@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async () => {
redirect(302, '/login')
redirect(302, '/login');
}) satisfies PageServerLoad;

View File

@@ -1,15 +0,0 @@
<script lang="ts">
import { Fan } from 'lucide-svelte';
import type { PageData } from './$types';
import LoginForm from './login-form.svelte';
export let data: PageData;
</script>
<div class="flex h-full w-full flex-col items-center justify-center gap-8">
<Fan class="h-32 w-32" />
<div class="w-[20%] min-w-[300px] space-y-4 rounded-lg bg-secondary p-4">
<h1 class="text-center text-4xl font-bold">Login</h1>
<LoginForm data={data.form}></LoginForm>
</div>
</div>

View File

@@ -1,14 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { PageData } from './$types';
import RegisterForm from './register-form.svelte';
export let data: PageData;
</script>
<div class="flex h-full w-full items-center justify-center">
<div class="w-[20%] min-w-[300px] space-y-4 rounded-lg bg-secondary p-4">
<h1 class="text-center text-4xl font-bold">Register</h1>
<RegisterForm data={data.form}></RegisterForm>
</div>
</div>

View File

@@ -1,64 +1,64 @@
import { fontFamily } from "tailwindcss/defaultTheme";
import type { Config } from "tailwindcss";
import { fontFamily } from 'tailwindcss/defaultTheme';
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
darkMode: ['class'],
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ['dark'],
theme: {
container: {
center: true,
padding: "2rem",
padding: '2rem',
screens: {
"2xl": "1400px"
'2xl': '1400px'
}
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
border: 'hsl(var(--border) / <alpha-value>)',
input: 'hsl(var(--input) / <alpha-value>)',
ring: 'hsl(var(--ring) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)'
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)'
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)'
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)'
},
accent: {
DEFAULT: "#AD5CD6",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
DEFAULT: '#AD5CD6',
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)'
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)'
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)'
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
sans: [...fontFamily.sans]
}
}
},
}
};
export default config;