a
This commit is contained in:
16
.prettierrc
16
.prettierrc
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
package.json
11
package.json
@@ -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
2
src/app.d.ts
vendored
@@ -10,4 +10,4 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export { };
|
||||
|
||||
26
src/app.html
26
src/app.html
@@ -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
6
src/hooks.server.ts
Normal 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
30
src/lib/api/channel.ts
Normal 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
10
src/lib/api/message.ts
Normal 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
22
src/lib/api/user.ts
Normal 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
31
src/lib/api/utils.ts
Normal 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;
|
||||
}
|
||||
16
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
16
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal 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>
|
||||
18
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
18
src/lib/components/ui/avatar/avatar-image.svelte
Normal 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}
|
||||
/>
|
||||
18
src/lib/components/ui/avatar/avatar.svelte
Normal file
18
src/lib/components/ui/avatar/avatar.svelte
Normal 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>
|
||||
13
src/lib/components/ui/avatar/index.ts
Normal file
13
src/lib/components/ui/avatar/index.ts
Normal 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,
|
||||
};
|
||||
25
src/lib/components/ui/button/button.svelte
Normal file
25
src/lib/components/ui/button/button.svelte
Normal 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>
|
||||
49
src/lib/components/ui/button/index.ts
Normal file
49
src/lib/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
48
src/lib/components/ui/dropdown-menu/index.ts
Normal file
48
src/lib/components/ui/dropdown-menu/index.ts
Normal 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,
|
||||
};
|
||||
10
src/lib/components/ui/form/form-button.svelte
Normal file
10
src/lib/components/ui/form/form-button.svelte
Normal 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>
|
||||
17
src/lib/components/ui/form/form-description.svelte
Normal file
17
src/lib/components/ui/form/form-description.svelte
Normal 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>
|
||||
25
src/lib/components/ui/form/form-element-field.svelte
Normal file
25
src/lib/components/ui/form/form-element-field.svelte
Normal 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>
|
||||
26
src/lib/components/ui/form/form-field-errors.svelte
Normal file
26
src/lib/components/ui/form/form-field-errors.svelte
Normal 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>
|
||||
25
src/lib/components/ui/form/form-field.svelte
Normal file
25
src/lib/components/ui/form/form-field.svelte
Normal 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>
|
||||
30
src/lib/components/ui/form/form-fieldset.svelte
Normal file
30
src/lib/components/ui/form/form-fieldset.svelte
Normal 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>
|
||||
17
src/lib/components/ui/form/form-label.svelte
Normal file
17
src/lib/components/ui/form/form-label.svelte
Normal 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>
|
||||
17
src/lib/components/ui/form/form-legend.svelte
Normal file
17
src/lib/components/ui/form/form-legend.svelte
Normal 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>
|
||||
33
src/lib/components/ui/form/index.ts
Normal file
33
src/lib/components/ui/form/index.ts
Normal 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,
|
||||
};
|
||||
29
src/lib/components/ui/input/index.ts
Normal file
29
src/lib/components/ui/input/index.ts
Normal 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,
|
||||
};
|
||||
42
src/lib/components/ui/input/input.svelte
Normal file
42
src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
21
src/lib/components/ui/label/label.svelte
Normal file
21
src/lib/components/ui/label/label.svelte
Normal 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>
|
||||
17
src/lib/components/ui/popover/index.ts
Normal file
17
src/lib/components/ui/popover/index.ts
Normal 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,
|
||||
};
|
||||
22
src/lib/components/ui/popover/popover-content.svelte
Normal file
22
src/lib/components/ui/popover/popover-content.svelte
Normal 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>
|
||||
10
src/lib/components/ui/scroll-area/index.ts
Normal file
10
src/lib/components/ui/scroll-area/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
66
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
66
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal 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>
|
||||
7
src/lib/components/ui/separator/index.ts
Normal file
7
src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
22
src/lib/components/ui/separator/separator.svelte
Normal file
22
src/lib/components/ui/separator/separator.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/skeleton/index.ts
Normal file
7
src/lib/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./skeleton.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
};
|
||||
11
src/lib/components/ui/skeleton/skeleton.svelte
Normal file
11
src/lib/components/ui/skeleton/skeleton.svelte
Normal 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>
|
||||
1
src/lib/components/ui/sonner/index.ts
Normal file
1
src/lib/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
20
src/lib/components/ui/sonner/sonner.svelte
Normal file
20
src/lib/components/ui/sonner/sonner.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/switch/index.ts
Normal file
7
src/lib/components/ui/switch/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./switch.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch,
|
||||
};
|
||||
28
src/lib/components/ui/switch/switch.svelte
Normal file
28
src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||
28
src/lib/components/ui/textarea/index.ts
Normal file
28
src/lib/components/ui/textarea/index.ts
Normal 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,
|
||||
};
|
||||
38
src/lib/components/ui/textarea/textarea.svelte
Normal file
38
src/lib/components/ui/textarea/textarea.svelte
Normal 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
1
src/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_URL = 'http://localhost:1234';
|
||||
42
src/lib/stores/cache/messages.ts
vendored
Normal file
42
src/lib/stores/cache/messages.ts
vendored
Normal 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
47
src/lib/stores/cache/users.ts
vendored
Normal 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
22
src/lib/stores/user.ts
Normal 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
36
src/lib/types.ts
Normal 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;
|
||||
};
|
||||
19
src/routes/(auth)/+layout.server.ts
Normal file
19
src/routes/(auth)/+layout.server.ts
Normal 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;
|
||||
12
src/routes/(auth)/+layout.svelte
Normal file
12
src/routes/(auth)/+layout.svelte
Normal 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 />
|
||||
1
src/routes/(auth)/+layout.ts
Normal file
1
src/routes/(auth)/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false
|
||||
44
src/routes/(auth)/chats/(components)/(chat)/chat.svelte
Normal file
44
src/routes/(auth)/chats/(components)/(chat)/chat.svelte
Normal 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>
|
||||
@@ -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>
|
||||
46
src/routes/(auth)/chats/(components)/(chat)/message.svelte
Normal file
46
src/routes/(auth)/chats/(components)/(chat)/message.svelte
Normal 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>
|
||||
@@ -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>
|
||||
25
src/routes/(auth)/chats/(components)/chat-list-item.svelte
Normal file
25
src/routes/(auth)/chats/(components)/chat-list-item.svelte
Normal 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>
|
||||
32
src/routes/(auth)/chats/(components)/chat-list.svelte
Normal file
32
src/routes/(auth)/chats/(components)/chat-list.svelte
Normal 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>
|
||||
43
src/routes/(auth)/chats/(components)/sidebar-header.svelte
Normal file
43
src/routes/(auth)/chats/(components)/sidebar-header.svelte
Normal 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>
|
||||
18
src/routes/(auth)/chats/(components)/sidebar.svelte
Normal file
18
src/routes/(auth)/chats/(components)/sidebar.svelte
Normal 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>
|
||||
67
src/routes/(auth)/chats/+layout.svelte
Normal file
67
src/routes/(auth)/chats/+layout.svelte
Normal 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>
|
||||
11
src/routes/(auth)/chats/+layout.ts
Normal file
11
src/routes/(auth)/chats/+layout.ts
Normal 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;
|
||||
5
src/routes/(auth)/chats/+page.svelte
Normal file
5
src/routes/(auth)/chats/+page.svelte
Normal 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>
|
||||
13
src/routes/(auth)/chats/[username]/+page.svelte
Normal file
13
src/routes/(auth)/chats/[username]/+page.svelte
Normal 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>
|
||||
18
src/routes/(auth)/chats/[username]/+page.ts
Normal file
18
src/routes/(auth)/chats/[username]/+page.ts
Normal 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;
|
||||
@@ -2,6 +2,6 @@
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<slot></slot>
|
||||
|
||||
<style></style>
|
||||
<div class="h-full w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
6
src/routes/+page.server.ts
Normal file
6
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
redirect(302, '/login')
|
||||
}) satisfies PageServerLoad;
|
||||
@@ -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>
|
||||
35
src/routes/login/+page.server.ts
Normal file
35
src/routes/login/+page.server.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
15
src/routes/login/+page.svelte
Normal file
15
src/routes/login/+page.svelte
Normal 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>
|
||||
79
src/routes/login/login-form.svelte
Normal file
79
src/routes/login/login-form.svelte
Normal 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>
|
||||
7
src/routes/logout/+page.server.ts
Normal file
7
src/routes/logout/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async ({ cookies }) => {
|
||||
cookies.delete('token', { path: '/' });
|
||||
|
||||
return {};
|
||||
}) satisfies PageServerLoad;
|
||||
11
src/routes/logout/+page.svelte
Normal file
11
src/routes/logout/+page.svelte
Normal 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>
|
||||
29
src/routes/register/+page.server.ts
Normal file
29
src/routes/register/+page.server.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
14
src/routes/register/+page.svelte
Normal file
14
src/routes/register/+page.svelte
Normal 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>
|
||||
95
src/routes/register/register-form.svelte
Normal file
95
src/routes/register/register-form.svelte
Normal 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
BIN
static/default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user