1
0
This commit is contained in:
2024-05-19 18:59:16 +03:00
parent 621d267ea4
commit 8dedbf208c
89 changed files with 2085 additions and 33 deletions

View File

@@ -1,8 +1,18 @@
{
"useTabs": true,
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -35,8 +35,17 @@
},
"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"
"tailwind-variants": "^0.2.1",
"zod": "^3.23.8"
}
}

2
src/app.d.ts vendored
View File

@@ -10,4 +10,4 @@ declare global {
}
}
export {};
export { };

View File

@@ -1,12 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, height=device-height" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
</head>
<style>
html,
body {
height: 100vh;
width: 100vw;
margin: 0;
background-color: black;
}
</style>
<body data-sveltekit-preload-data="hover" class="dark">
<div style="display: contents">%sveltekit.body%</div>
</body>
</body>
</html>

6
src/hooks.server.ts Normal file
View File

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

30
src/lib/api/channel.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Channel, Message } from '../types';
import { apiRequest } from './utils';
export async function getAllChannels() {
return await apiRequest<Channel[]>('/channel', 'get');
}
export async function getChannelById(channelId: number) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'get');
}
export async function createChannel(name: string) {
return await apiRequest<Channel>('/channel', 'post', { data: { name } });
}
export async function deleteChannel(channelId: number) {
return await apiRequest<Channel>(`/channel/${channelId}`, 'delete');
}
export async function addUserToChannel(channelId: number, userId: number) {
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'post');
}
export async function removeUserFromChannel(channelId: number, userId: number) {
return await apiRequest<unknown>(`/channel/${channelId}/user/${userId}`, 'delete');
}
export async function getMessagesByChannelId(channelId: number, before_id: number, limit?: number) {
return await apiRequest<Message[]>(`/channel/${channelId}/message`, 'get', { data: { before_id, limit } });
}

10
src/lib/api/message.ts Normal file
View File

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

22
src/lib/api/user.ts Normal file
View File

@@ -0,0 +1,22 @@
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 });
}
export async function getUserById(userId: number) {
return await apiRequest<User>(`/user/${userId}`, 'get');
}
export async function getUserByUsername(username: string) {
return await apiRequest<User>(`/user/username/${username}`, 'get');
}
export async function loginUser(username: string, password: string) {
return await apiRequest<Token>('/user/login', 'post', { data: { username, password } });
}
export async function registerUser(username: string, password: string) {
return await apiRequest<Token>('/user/register', 'post', { data: { username, password } });
}

31
src/lib/api/utils.ts Normal file
View File

@@ -0,0 +1,31 @@
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> {
const url = API_URL + path;
const token = options?.token || getUserToken();
options = {
url,
method,
headers: {
...options?.headers,
'Authorization': `Bearer ${token}`
},
validateStatus: () => true,
...options,
}
const response = await axios.request(options);
if (response.status === 200)
return response.data as T;
else
return response.data as ErrorResponse;
}

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = AvatarPrimitive.FallbackProps;
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)}
{...$$restProps}
>
<slot />
</AvatarPrimitive.Fallback>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
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;
export { className as class };
</script>
<AvatarPrimitive.Image
{src}
{alt}
class={cn("aspect-square h-full w-full", className)}
{...$$restProps}
/>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
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;
export { className as class };
</script>
<AvatarPrimitive.Root
{delayMs}
class={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...$$restProps}
>
<slot />
</AvatarPrimitive.Root>

View File

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

View File

@@ -0,0 +1,25 @@
<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";
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"] = [];
export { className as class };
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type="button"
{...$$restProps}
on:click
on:keydown
>
<slot />
</ButtonPrimitive.Root>

View File

@@ -0,0 +1,49 @@
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",
variants: {
variant: {
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",
},
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",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
size?: Size;
};
type Events = ButtonPrimitive.Events;
export {
Root,
type Props,
type Events,
//
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
};

View File

@@ -0,0 +1,35 @@
<script lang="ts">
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;
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",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.CheckboxIndicator>
<Check class="h-4 w-4" />
</DropdownMenuPrimitive.CheckboxIndicator>
</span>
<slot />
</DropdownMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
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;
export { className as class };
</script>
<DropdownMenuPrimitive.Content
{transition}
{transitionConfig}
{sideOffset}
class={cn(
"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none",
className
)}
{...$$restProps}
on:keydown
>
<slot />
</DropdownMenuPrimitive.Content>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
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;
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",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<slot />
</DropdownMenuPrimitive.Item>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
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;
export { className as class };
</script>
<DropdownMenuPrimitive.Label
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...$$restProps}
>
<slot />
</DropdownMenuPrimitive.Label>

View File

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

View File

@@ -0,0 +1,35 @@
<script lang="ts">
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"];
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",
className
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.RadioIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.RadioIndicator>
</span>
<slot />
</DropdownMenuPrimitive.RadioItem>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
<script lang="ts">
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;
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",
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,48 @@
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;
const Trigger = DropdownMenuPrimitive.Trigger;
const Group = DropdownMenuPrimitive.Group;
export {
Sub,
Root,
Item,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as DropdownMenu,
Sub as DropdownMenuSub,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
Group as DropdownMenuGroup,
Content as DropdownMenuContent,
Trigger as DropdownMenuTrigger,
Shortcut as DropdownMenuShortcut,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
RadioGroup as DropdownMenuRadioGroup,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
CheckboxItem as DropdownMenuCheckboxItem,
};

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import * as Button from "$lib/components/ui/button/index.js";
type $$Props = Button.Props;
type $$Events = Button.Events;
</script>
<Button.Root type="submit" on:click on:keydown {...$$restProps}>
<slot />
</Button.Root>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
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;
export { className as class };
</script>
<FormPrimitive.Description
class={cn("text-sm text-muted-foreground", className)}
{...$$restProps}
let:descriptionAttrs
>
<slot {descriptionAttrs} />
</FormPrimitive.Description>

View File

@@ -0,0 +1,25 @@
<script lang="ts" context="module">
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";
type $$Props = FormPrimitive.ElementFieldProps<T, U> & HTMLAttributes<HTMLElement>;
export let form: SuperForm<T>;
export let name: U;
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)}>
<slot {constraints} {errors} {tainted} {value} />
</div>
</FormPrimitive.ElementField>

View File

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

View File

@@ -0,0 +1,25 @@
<script lang="ts" context="module">
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";
type $$Props = FormPrimitive.FieldProps<T, U> & HTMLAttributes<HTMLElement>;
export let form: SuperForm<T>;
export let name: U;
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)}>
<slot {constraints} {errors} {tainted} {value} />
</div>
</FormPrimitive.Field>

View File

@@ -0,0 +1,30 @@
<script lang="ts" context="module">
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";
type $$Props = FormPrimitive.FieldsetProps<T, U>;
export let form: SuperForm<T>;
export let name: U;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<FormPrimitive.Fieldset
{form}
{name}
let:constraints
let:errors
let:tainted
let:value
class={cn("space-y-2", className)}
>
<slot {constraints} {errors} {tainted} {value} />
</FormPrimitive.Fieldset>

View File

@@ -0,0 +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";
type $$Props = LabelPrimitive.Props;
let className: $$Props["class"] = undefined;
export { className as class };
const { labelAttrs } = getFormControl();
</script>
<Label {...$labelAttrs} class={cn("data-[fs-error]:text-destructive", className)} {...$$restProps}>
<slot {labelAttrs} />
</Label>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import { cn } from "$lib/utils.js";
type $$Props = FormPrimitive.LegendProps;
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)}
let:legendAttrs
>
<slot {legendAttrs} />
</FormPrimitive.Legend>

View File

@@ -0,0 +1,33 @@
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;
export {
Field,
Control,
Label,
Button,
FieldErrors,
Description,
Fieldset,
Legend,
ElementField,
//
Field as FormField,
Control as FormControl,
Description as FormDescription,
Label as FormLabel,
FieldErrors as FormFieldErrors,
Fieldset as FormFieldset,
Legend as FormLegend,
ElementField as FormElementField,
Button as FormButton,
};

View File

@@ -0,0 +1,29 @@
import Root from "./input.svelte";
export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement;
};
export type InputEvents = {
blur: FormInputEvent<FocusEvent>;
change: FormInputEvent<Event>;
click: FormInputEvent<MouseEvent>;
focus: FormInputEvent<FocusEvent>;
focusin: FormInputEvent<FocusEvent>;
focusout: FormInputEvent<FocusEvent>;
keydown: FormInputEvent<KeyboardEvent>;
keypress: FormInputEvent<KeyboardEvent>;
keyup: FormInputEvent<KeyboardEvent>;
mouseover: FormInputEvent<MouseEvent>;
mouseenter: FormInputEvent<MouseEvent>;
mouseleave: FormInputEvent<MouseEvent>;
mousemove: FormInputEvent<MouseEvent>;
paste: FormInputEvent<ClipboardEvent>;
input: FormInputEvent<InputEvent>;
wheel: FormInputEvent<WheelEvent>;
};
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
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;
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;
</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",
className
)}
bind:value
{readonly}
on:blur
on:change
on:click
on:focus
on:focusin
on:focusout
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:mousemove
on:paste
on:input
on:wheel|passive
{...$$restProps}
/>

View File

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

View File

@@ -0,0 +1,21 @@
<script lang="ts">
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;
export { className as class };
</script>
<LabelPrimitive.Root
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...$$restProps}
on:mousedown
>
<slot />
</LabelPrimitive.Root>

View File

@@ -0,0 +1,17 @@
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;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};

View File

@@ -0,0 +1,22 @@
<script lang="ts">
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;
export { className as class };
</script>
<PopoverPrimitive.Content
{transition}
{transitionConfig}
class={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
className
)}
{...$$restProps}
>
<slot />
</PopoverPrimitive.Content>

View File

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

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = ScrollAreaPrimitive.ScrollbarProps & {
orientation?: "vertical" | "horizontal";
};
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",
className
)}
>
<slot />
<ScrollAreaPrimitive.Thumb
class={cn("relative rounded-full bg-border", orientation === "vertical" && "flex-1")}
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { Scrollbar } from './index.js';
import { cn } from '$lib/utils.js';
import { onMount } from 'svelte';
type $$Props = ScrollAreaPrimitive.Props & {
orientation?: 'vertical' | 'horizontal' | 'both';
scrollbarXClasses?: string;
scrollbarYClasses?: string;
scrollToBottom?: boolean;
};
let className: $$Props['class'] = undefined;
export { className as class };
export let orientation = 'vertical';
export let scrollbarXClasses: string = '';
export let scrollbarYClasses: string = '';
export let scrollToBottom: boolean = false;
let viewport: HTMLDivElement;
const scrollBottom = (node: HTMLDivElement, top: number) => {
const scroll = () =>
node.scroll({
top,
behavior: 'instant'
});
scroll();
return { update: scroll };
};
onMount(() => {
if (scrollToBottom) {
scrollBottom(viewport, viewport.scrollHeight);
}
});
export const scroll = (anchor: 'top' | 'bottom') => {
if (anchor === 'bottom') {
scrollBottom(viewport, viewport.scrollHeight);
}
if (anchor === 'top') {
scrollBottom(viewport, 0);
}
};
</script>
<ScrollAreaPrimitive.Root {...$$restProps} class={cn('relative overflow-hidden', className)}>
<ScrollAreaPrimitive.Viewport class="h-full w-full rounded-[inherit]" bind:el={viewport}>
<ScrollAreaPrimitive.Content>
<slot />
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View File

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

View File

@@ -0,0 +1,22 @@
<script lang="ts">
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;
export { className as class };
</script>
<SeparatorPrimitive.Root
class={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{orientation}
{decorative}
{...$$restProps}
/>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
type $$Props = SonnerProps;
</script>
<Sonner
theme={$mode}
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",
},
}}
{...$$restProps}
/>

View File

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

View File

@@ -0,0 +1,28 @@
<script lang="ts">
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;
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",
className
)}
{...$$restProps}
on:click
on:keydown
>
<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"
)}
/>
</SwitchPrimitive.Root>

View File

@@ -0,0 +1,28 @@
import Root from "./textarea.svelte";
type FormTextareaEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLTextAreaElement;
};
type TextareaEvents = {
blur: FormTextareaEvent<FocusEvent>;
change: FormTextareaEvent<Event>;
click: FormTextareaEvent<MouseEvent>;
focus: FormTextareaEvent<FocusEvent>;
keydown: FormTextareaEvent<KeyboardEvent>;
keypress: FormTextareaEvent<KeyboardEvent>;
keyup: FormTextareaEvent<KeyboardEvent>;
mouseover: FormTextareaEvent<MouseEvent>;
mouseenter: FormTextareaEvent<MouseEvent>;
mouseleave: FormTextareaEvent<MouseEvent>;
paste: FormTextareaEvent<ClipboardEvent>;
input: FormTextareaEvent<InputEvent>;
};
export {
Root,
//
Root as Textarea,
type TextareaEvents,
type FormTextareaEvent,
};

View File

@@ -0,0 +1,38 @@
<script lang="ts">
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;
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;
</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",
className
)}
bind:value
{readonly}
on:blur
on:change
on:click
on:focus
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:paste
on:input
{...$$restProps}
></textarea>

1
src/lib/constants.ts Normal file
View File

@@ -0,0 +1 @@
export const API_URL = 'http://localhost:1234';

42
src/lib/stores/cache/messages.ts vendored Normal file
View File

@@ -0,0 +1,42 @@
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;
}

47
src/lib/stores/cache/users.ts vendored Normal file
View File

@@ -0,0 +1,47 @@
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;
}

22
src/lib/stores/user.ts Normal file
View File

@@ -0,0 +1,22 @@
import { derived, get, writable } from "svelte/store";
import { isErrorResponse, type User } from "../types";
import { getByToken } from "$lib/api/user";
export const token = writable<string | null>(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);
})
});
export function getUserToken(): string | null {
return get(token);
}

36
src/lib/types.ts Normal file
View File

@@ -0,0 +1,36 @@
export type ErrorResponse = {
error: string;
}
export function isErrorResponse(data: unknown): data is ErrorResponse {
return ((data as ErrorResponse).error !== undefined);
}
export type Token = {
token: string;
userId: number;
createdAt: Date;
expiresAt: Date;
}
export type User = {
id: number;
username: string;
avatar: string | null;
createdAt: Date;
};
export type Message = {
id: number;
channelId: number;
authorId: number;
content: string;
createdAt: Date;
};
export type Channel = {
id: number;
name: string;
lastMessageId: number | null;
createdAt: Date;
};

View File

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

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import type { LayoutData } from './$types';
import { token, user } from '$lib/stores/user';
import { addUserToCache } from '$lib/stores/cache/users';
export let data: LayoutData;
$token = data.token;
addUserToCache(data.user);
</script>
<slot />

View File

@@ -0,0 +1 @@
export const ssr = false

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import type { Message as MessageType } from '$lib/types';
import MessageArea from './message-area.svelte';
import Message from './message.svelte';
import TextField from './text-field.svelte';
export let username: string;
export let messages: MessageType[] = [];
let messageArea: MessageArea;
</script>
<div class="flex h-screen w-[95%] flex-col contain-strict">
<div class="contents">
<div class="flex items-center space-x-4">
<Avatar.Root class="h-12 w-12">
<Avatar.Image src="/default-avatar.png" />
<Avatar.Fallback>{username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
<span class="text-xl font-bold">{username}</span>
</div>
</div>
<div class="contents flex-grow">
<MessageArea {messages} bind:this={messageArea} />
</div>
<div class="m-4 mt-1 max-h-[40%] flex-grow">
<TextField
onSend={(message) => {
messages = [
...messages,
{ content: message, createdAt: new Date(), authorId: 1, id: 1 }
];
setTimeout(() => {
messageArea.scroll('bottom');
}, 100);
}}
/>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { Message as MessageType } from '$lib/types';
import Message from './message.svelte';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
export let messages: MessageType[] = [];
let scrollArea: ScrollArea;
messages = [...messages].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
export function scroll(anchor: 'top' | 'bottom') {
if (scrollArea) {
scrollArea.scroll(anchor);
}
}
</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">
{#each messages as message}
<Message {message} />
{/each}
</div>
</ScrollArea>
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { Message, User } from '$lib/types';
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 { 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));
$: 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';
</script>
<div class="w-full contain-inline-size">
<div class={cn('flex flex-row justify-between space-x-2', position)}>
{#if !isSelf}
<Avatar.Root class="h-16 w-16">
<Avatar.Fallback>{username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
{/if}
<div class={cn('flex max-w-[60%] flex-col rounded-2xl px-4 py-2', color)}>
<span class="whitespace-pre-line break-words break-all text-left text-xl font-bold">
{message.content}
</span>
<span class={cn('text-md', timestampPosition)}
>{message.createdAt.toLocaleString()}
</span>
</div>
{#if isSelf}
<Avatar.Root class="h-16 w-16">
<Avatar.Fallback>{username[0].toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
{/if}
</div>
</div>

View File

@@ -0,0 +1,56 @@
<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';
export let onSend: (message: string) => void = () => {};
let content = '';
function onPaste(event: ClipboardEvent) {
event.preventDefault();
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 = '';
}
}
</script>
<div class="flex h-full items-center gap-2 rounded-3xl bg-secondary p-2">
<ScrollArea class="h-full w-full">
<div
contenteditable="true"
class="h-full w-full break-words break-all contain-layout"
bind:innerText={content}
on:paste={onPaste}
></div>
</ScrollArea>
<Button
on:click={onSendClick}
class="m-2 h-fit w-fit place-self-start rounded-full p-2"
variant="ghost"
>
<Send />
</Button>
</div>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import { cn } from '$lib/utils';
export let username: string;
export let lastMessage: string;
export let avatarUrl: string | undefined | null;
export let selected: boolean = false;
export let onClick: () => void = () => {};
$: className = selected ? 'bg-accent' : 'hover:bg-secondary';
</script>
<button on:click={onClick} class={cn('flex w-full space-x-4 rounded-xl p-4', className)}>
<div>
<Avatar.Root class="h-12 w-12">
<Avatar.Image src={avatarUrl} />
<Avatar.Fallback>{username[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">{username}</span>
<span class="overflow-hidden text-ellipsis text-left text-sm">{lastMessage}</span>
</div>
</button>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { writable, type Writable } from 'svelte/store';
import ChatListItem from './chat-list-item.svelte';
import { goto } from '$app/navigation';
import type { Channel } from '$lib/types';
export let chats: Channel[] = [];
export let defaultSelected: string | undefined = undefined;
const selectedChat: Writable<string | undefined> = writable(defaultSelected);
export function select(username: string) {
selectedChat.set(username);
goto(`/chats/${username}`);
}
export function deselect() {
selectedChat.set(undefined);
}
</script>
<div class="flex flex-col gap-2">
{#each chats as chat}
<ChatListItem
username={chat.username}
lastMessage={chat.lastMessage}
avatarUrl={chat.avatarUrl}
selected={$selectedChat == chat.username}
onClick={() => select(chat.username)}
/>
{/each}
</div>

View File

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

View File

@@ -0,0 +1,18 @@
<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}`);
</script>
<div>
<div>
<slot name="header"></slot>
</div>
<div>
<ScrollArea class="flex h-[calc(100vh-4rem)] flex-grow rounded-md">
<div class="p-2 pr-3">
<slot name="chats"></slot>
</div>
</ScrollArea>
</div>
</div>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
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 type { ComponentType } from 'svelte';
import type { MenuItem } from './(components)/sidebar-header.svelte';
import ChatList from './(components)/chat-list.svelte';
import { user } from '$lib/stores/user';
export let data: LayoutData;
let chatList: ChatList | undefined;
function onKeyUp(event: KeyboardEvent) {
if (event.key === 'Escape') {
chatList?.deselect();
goto('/chats');
}
}
const menuItems: MenuItem[] = [
{
name: 'Settings',
icon: Settings as ComponentType<Icon>,
onClick: () => {
goto('/settings');
}
},
{
name: 'Logout',
icon: LogOut as ComponentType<Icon>,
onClick: () => {
goto('/logout');
}
}
];
</script>
<svelte:window on:keyup={onKeyUp} />
<div class="flex flex-grow flex-row">
<div class="hidden max-w-[370px] sm:w-[50%] md:inline-block">
<Sidebar>
<div slot="header">
<SidebarHeader {menuItems} />
</div>
<div slot="chats">
<ChatList
chats={data.chats}
defaultSelected={$page.params.username}
bind:this={chatList}
/>
</div>
</Sidebar>
</div>
<div class="h-full w-full contain-size">
<slot></slot>
</div>
</div>

View File

@@ -0,0 +1,11 @@
import type { LayoutLoad } from './$types';
const chats = [
{ username: 'lionarius', lastMessage: 'Привет', avatarUrl: '/default-avatar.png' },
{ username: 'tenebris', lastMessage: 'Как дела', avatarUrl: null },
{ username: 'staheys', lastMessage: 'Hey', avatarUrl: '/default-avatar.png' },
]
export const load = (async () => {
return { chats };
}) satisfies LayoutLoad;

View File

@@ -0,0 +1,5 @@
<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">No chat selected</h1>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { PageData } from './$types';
import { page } from '$app/stores';
import Chat from '../(components)/(chat)/chat.svelte';
export let data: PageData;
$: username = $page.params.username;
</script>
<div class="flex h-screen w-full items-center justify-center">
<Chat {username} messages={data.messages} />
</div>

View File

@@ -0,0 +1,18 @@
import type { PageLoad } from './$types';
const messages = [
{ content: риветdsfsdfsdfsdf', senderId: 1, createdAt: new Date(Date.now() - 1000 * 60 * 25) },
{ content: риветffffffffffdsfsdfsdfffffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsffffffffdsfsdfsdffвеffffffffffdsfsdfsdffвеffветdsfsdfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff дела?', senderId: 2, createdAt: new Date(Date.now() - 1000 * 60 * 20) },
{ content: 'Хорошо', senderId: 1, createdAt: new Date(Date.now() - 1000 * 60 * 15) },
{ content: 'Привет', senderId: 2, createdAt: new Date(Date.now() - 1000 * 60 * 10) },
{ content: риветdsfsdfsdfsdf', senderId: 1, createdAt: new Date(Date.now() - 1000 * 60 * 25) },
{ content: 'fdsfdsfsdfКак дела?', senderId: 2, createdAt: new Date(Date.now() - 1000 * 60 * 20) },
{ content: 'Хорошо', senderId: 1, createdAt: new Date(Date.now() - 1000 * 60 * 15) },
{ content: риветffffffffffdsfsdfsdfffffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsffffffffdsfsdfsdffвеffffffffffdsfsdfsdffвеffветdsfsdfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', senderId: 2, createdAt: new Date(Date.now() - 1000 * 60 * 10) },
{ content: риветffffffffffdsfsdfsdfffffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsfffffffdsfsdfsdffffffffffdsffffffffdsfsdfsdffвеffffffffffdsfsdfsdffвеffветdsfsdfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', senderId: 1, createdAt: new Date(Date.now() - 1000 * 60 * 25) },
]
export const load = (async () => {
return { messages };
}) satisfies PageLoad;

View File

@@ -2,6 +2,6 @@
import '../app.css';
</script>
<slot></slot>
<style></style>
<div class="h-full w-full">
<slot></slot>
</div>

View File

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

View File

@@ -1,2 +0,0 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

View File

@@ -0,0 +1,35 @@
import type { PageServerLoad } from './$types';
import { zod } from "sveltekit-superforms/adapters";
import { loginFormSchema } from './login-form.svelte';
import { superValidate } from "sveltekit-superforms";
import { type Actions, fail, redirect } from "@sveltejs/kit";
import { loginUser } from '$lib/api/user';
import { isErrorResponse } from '$lib/types';
export const load = (async ({ cookies }) => {
if (cookies.get('token'))
throw redirect(302, '/chats');
return { form: await superValidate(zod(loginFormSchema)) };
}) satisfies PageServerLoad;
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod(loginFormSchema));
if (!form.valid)
return fail(400, { form });
const response = await loginUser(form.data.username, form.data.password);
if (isErrorResponse(response)) {
form.errors.username = [response.error];
return fail(400, { form });
}
event.cookies.set('token', response.token, { path: '/' });
return { form, token: response };
},
};

View File

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

@@ -0,0 +1,79 @@
<script lang="ts" context="module">
import { z } from 'zod';
export const loginFormSchema = z.object({
username: z
.string()
.min(6, { message: 'Username must be at least 6 characters' })
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, {
message: 'Username can only contain letters, numbers, and underscores'
}),
password: z.string().min(1, { message: 'Password is required' })
});
export type LoginFormSchema = z.infer<typeof loginFormSchema>;
</script>
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import * as Form from '$lib/components/ui/form';
import { superForm, type SuperValidated } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters';
import { token as tokenStore } from '$lib/stores/user';
import type { Token } from '$lib/types';
export let data: SuperValidated<LoginFormSchema>;
export let registerUrl: string = '/register';
const form = superForm(data, {
validators: zodClient(loginFormSchema),
onResult: ({ result }) => {
if (result.type === 'success') {
const token = result.data?.token as Token;
if (token) {
tokenStore.set(token.token);
window.location.href = '/chats';
}
}
}
});
const { form: formData, enhance } = form;
</script>
<form method="post" use:enhance class="flex flex-col">
<div>
<Form.Field name="username" {form}>
<Form.Control let:attrs>
<Form.Label>Username</Form.Label>
<Input
{...attrs}
bind:value={$formData.username}
type="text"
placeholder="username"
/>
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Field name="password" {form}>
<Form.Control let:attrs>
<Form.Label>Password</Form.Label>
<Input
{...attrs}
bind:value={$formData.password}
type="password"
placeholder="password"
/>
<Form.FieldErrors />
</Form.Control>
</Form.Field>
</div>
<div class="flex flex-col space-y-2 pt-4">
<Form.Button>Login</Form.Button>
<Button variant="link" href={registerUrl}>Don't have an account? Register</Button>
</div>
</form>

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
export const load = (async ({ cookies }) => {
cookies.delete('token', { path: '/' });
return {};
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { token } from '$lib/stores/user';
if (browser) {
$token = null;
goto('/login');
}
</script>

View File

@@ -0,0 +1,29 @@
import type { PageServerLoad } from './$types';
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';
export const load = (async () => {
return { form: await superValidate(zod(registerFormSchema)) };
}) satisfies PageServerLoad;
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod(registerFormSchema));
if (!form.valid)
return fail(400, { form });
const response = await registerUser(form.data.username, form.data.password);
if (isErrorResponse(response)) {
form.errors.username = [response.error];
return fail(400, { form });
}
return { form, success: true };
}
}

View File

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

@@ -0,0 +1,95 @@
<script lang="ts" context="module">
import { z } from 'zod';
export const registerFormSchema = z
.object({
username: z
.string()
.min(6, { message: 'Username must be at least 6 characters' })
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, {
message: 'Username can only contain letters, numbers, and underscores'
}),
password: z.string().min(4, { message: 'Password is required' }),
confirmPassword: z.string().min(4, { message: 'Confirm password is required' })
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
export type RegisterFormSchema = z.infer<typeof registerFormSchema>;
</script>
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import * as Form from '$lib/components/ui/form';
import { superForm, type SuperValidated } from 'sveltekit-superforms';
import { 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';
const form = superForm(data, {
validators: zodClient(registerFormSchema),
onResult: ({ result }) => {
if (result.type === 'success') {
const success = result.data?.success;
if (success) {
window.location.href = loginUrl;
}
}
}
});
const { form: formData, enhance } = form;
</script>
<form method="post" use:enhance class="flex flex-col">
<div>
<Form.Field name="username" {form}>
<Form.Control let:attrs>
<Form.Label>Username</Form.Label>
<Input
{...attrs}
bind:value={$formData.username}
type="text"
placeholder="username"
/>
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Field name="password" {form}>
<Form.Control let:attrs>
<Form.Label>Password</Form.Label>
<Input
{...attrs}
bind:value={$formData.password}
type="password"
placeholder="password"
/>
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Field name="confirmPassword" {form}>
<Form.Control let:attrs>
<Form.Label>Confirm Password</Form.Label>
<Input
{...attrs}
bind:value={$formData.confirmPassword}
type="password"
placeholder="confirm password"
/>
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<div class="flex flex-col space-y-2 pt-4">
<Form.Button>Register</Form.Button>
<Button variant="link" href={loginUrl}>Already have an account? Login</Button>
</div>
</div>
</form>

BIN
static/default-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -37,7 +37,7 @@ const config: Config = {
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
DEFAULT: "#AD5CD6",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
},
popover: {