add swap card + bybit webhook
parent
0f4630a534
commit
b61f557380
@ -0,0 +1,11 @@
|
|||||||
|
// drizzle.config.ts
|
||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/lib/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL as string,
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,13 +1,24 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
|
|
||||||
|
import { user } from '$lib/db/schema'
|
||||||
|
import type { InferSelectModel } from 'drizzle-orm'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
interface Locals {
|
||||||
|
user: Omit<InferSelectModel<typeof user>, 'password'>
|
||||||
|
session: {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
expiresAt: number
|
||||||
|
} | null
|
||||||
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {}
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { json, type Handle } from '@sveltejs/kit'
|
||||||
|
import * as auth from '$lib/server/auth.js'
|
||||||
|
|
||||||
|
const handleAuth: Handle = async ({ event, resolve }) => {
|
||||||
|
const pathname = event.url.pathname
|
||||||
|
|
||||||
|
if (pathname.startsWith('/api')) {
|
||||||
|
const token = event.cookies.get('auth-session') ?? null
|
||||||
|
|
||||||
|
if (token === null) {
|
||||||
|
return json({}, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session, user } = await auth.validateSessionToken(token)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return json({}, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session !== null) {
|
||||||
|
auth.setSessionTokenCookie(
|
||||||
|
event,
|
||||||
|
token,
|
||||||
|
new Date(session.expiresAt),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
auth.deleteSessionTokenCookie(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
event.locals.session = session
|
||||||
|
event.locals.user = user
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handle = handleAuth
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
export const customFetch = async (
|
||||||
|
url: string,
|
||||||
|
props?: RequestInit,
|
||||||
|
) => {
|
||||||
|
const response = await fetch(url, { ...props })
|
||||||
|
|
||||||
|
if (response.status === 401) goto('/login')
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.FallbackProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
bind:ref
|
||||||
|
class={cn("bg-muted flex h-full w-full items-center justify-center rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.ImageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
bind:ref
|
||||||
|
class={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
class={cn("relative flex size-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
Command as CommandPrimitive,
|
||||||
|
Dialog as DialogPrimitive,
|
||||||
|
WithoutChildrenOrChild,
|
||||||
|
} from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import Command from "./command.svelte";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(""),
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
|
||||||
|
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
|
||||||
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open {...restProps}>
|
||||||
|
<Dialog.Content class="overflow-hidden p-0 shadow-lg" {portalProps}>
|
||||||
|
<Command
|
||||||
|
class="[&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-group]]:px-2 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
|
||||||
|
{...restProps}
|
||||||
|
bind:value
|
||||||
|
bind:ref
|
||||||
|
{children}
|
||||||
|
/>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.EmptyProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...restProps} />
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
heading,
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.GroupProps & {
|
||||||
|
heading?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
class={cn("text-foreground overflow-hidden p-1", className)}
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if heading}
|
||||||
|
<CommandPrimitive.GroupHeading
|
||||||
|
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</CommandPrimitive.GroupHeading>
|
||||||
|
{/if}
|
||||||
|
<CommandPrimitive.GroupItems {children} />
|
||||||
|
</CommandPrimitive.Group>
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import Search from "lucide-svelte/icons/search";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value = $bindable(""),
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.InputProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center border-b px-2" data-command-input-wrapper="">
|
||||||
|
<Search class="mr-2 size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
class={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
bind:value
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.ItemProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
class={cn(
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.LinkItemProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CommandPrimitive.LinkItem
|
||||||
|
class={cn(
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground 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-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.ListProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CommandPrimitive.List
|
||||||
|
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...restProps}
|
||||||
|
bind:ref
|
||||||
|
/>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.SeparatorProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} bind:ref {...restProps} />
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(""),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CommandPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CommandPrimitive.Root
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { Command as CommandPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Root from "./command.svelte";
|
||||||
|
import Dialog from "./command-dialog.svelte";
|
||||||
|
import Empty from "./command-empty.svelte";
|
||||||
|
import Group from "./command-group.svelte";
|
||||||
|
import Item from "./command-item.svelte";
|
||||||
|
import Input from "./command-input.svelte";
|
||||||
|
import List from "./command-list.svelte";
|
||||||
|
import Separator from "./command-separator.svelte";
|
||||||
|
import Shortcut from "./command-shortcut.svelte";
|
||||||
|
import LinkItem from "./command-link-item.svelte";
|
||||||
|
|
||||||
|
const Loading = CommandPrimitive.Loading;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Dialog,
|
||||||
|
Empty,
|
||||||
|
Group,
|
||||||
|
Item,
|
||||||
|
LinkItem,
|
||||||
|
Input,
|
||||||
|
List,
|
||||||
|
Separator,
|
||||||
|
Shortcut,
|
||||||
|
Loading,
|
||||||
|
//
|
||||||
|
Root as Command,
|
||||||
|
Dialog as CommandDialog,
|
||||||
|
Empty as CommandEmpty,
|
||||||
|
Group as CommandGroup,
|
||||||
|
Item as CommandItem,
|
||||||
|
LinkItem as CommandLinkItem,
|
||||||
|
Input as CommandInput,
|
||||||
|
List as CommandList,
|
||||||
|
Separator as CommandSeparator,
|
||||||
|
Shortcut as CommandShortcut,
|
||||||
|
Loading as CommandLoading,
|
||||||
|
};
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||||
|
import X from "lucide-svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Portal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
|
||||||
|
const Root = DialogPrimitive.Root;
|
||||||
|
const Trigger = DialogPrimitive.Trigger;
|
||||||
|
const Close = DialogPrimitive.Close;
|
||||||
|
const Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose,
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import Overlay from "./sheet-overlay.svelte";
|
||||||
|
import Content from "./sheet-content.svelte";
|
||||||
|
import Header from "./sheet-header.svelte";
|
||||||
|
import Footer from "./sheet-footer.svelte";
|
||||||
|
import Title from "./sheet-title.svelte";
|
||||||
|
import Description from "./sheet-description.svelte";
|
||||||
|
|
||||||
|
const Root = SheetPrimitive.Root;
|
||||||
|
const Close = SheetPrimitive.Close;
|
||||||
|
const Trigger = SheetPrimitive.Trigger;
|
||||||
|
const Portal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Close,
|
||||||
|
Trigger,
|
||||||
|
Portal,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Header,
|
||||||
|
Footer,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
//
|
||||||
|
Root as Sheet,
|
||||||
|
Close as SheetClose,
|
||||||
|
Trigger as SheetTrigger,
|
||||||
|
Portal as SheetPortal,
|
||||||
|
Overlay as SheetOverlay,
|
||||||
|
Content as SheetContent,
|
||||||
|
Header as SheetHeader,
|
||||||
|
Footer as SheetFooter,
|
||||||
|
Title as SheetTitle,
|
||||||
|
Description as SheetDescription,
|
||||||
|
};
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
export const sheetVariants = tv({
|
||||||
|
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 gap-4 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b",
|
||||||
|
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t",
|
||||||
|
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Side = VariantProps<typeof sheetVariants>["side"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||||
|
import X from "lucide-svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import SheetOverlay from "./sheet-overlay.svelte";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
side = "right",
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SheetPrimitive.PortalProps;
|
||||||
|
side?: Side;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Portal {...portalProps}>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content bind:ref class={cn(sheetVariants({ side }), className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPrimitive.Portal>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.OverlayProps = $props();
|
||||||
|
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
class={cn("text-foreground text-lg font-semibold", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
|
||||||
|
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
|
export const SIDEBAR_WIDTH = "16rem";
|
||||||
|
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
|
export const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
|
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
|
||||||
|
|
||||||
|
type Getter<T> = () => T;
|
||||||
|
|
||||||
|
export type SidebarStateProps = {
|
||||||
|
/**
|
||||||
|
* A getter function that returns the current open state of the sidebar.
|
||||||
|
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
|
||||||
|
* component.
|
||||||
|
*/
|
||||||
|
open: Getter<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that sets the open state of the sidebar. To support `bind:open`, we need
|
||||||
|
* a source of truth for changing the open state to ensure it will be synced throughout
|
||||||
|
* the sub-components and any `bind:` references.
|
||||||
|
*/
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SidebarState {
|
||||||
|
readonly props: SidebarStateProps;
|
||||||
|
open = $derived.by(() => this.props.open());
|
||||||
|
openMobile = $state(false);
|
||||||
|
setOpen: SidebarStateProps["setOpen"];
|
||||||
|
#isMobile: IsMobile;
|
||||||
|
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
|
||||||
|
|
||||||
|
constructor(props: SidebarStateProps) {
|
||||||
|
this.setOpen = props.setOpen;
|
||||||
|
this.#isMobile = new IsMobile();
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience getter for checking if the sidebar is mobile
|
||||||
|
// without this, we would need to use `sidebar.isMobile.current` everywhere
|
||||||
|
get isMobile() {
|
||||||
|
return this.#isMobile.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handler to apply to the `<svelte:window>`
|
||||||
|
handleShortcutKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setOpenMobile = (value: boolean) => {
|
||||||
|
this.openMobile = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle = () => {
|
||||||
|
return this.#isMobile.current
|
||||||
|
? (this.openMobile = !this.openMobile)
|
||||||
|
: this.setOpen(!this.open);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYMBOL_KEY = "scn-sidebar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new `SidebarState` instance and sets it in the context.
|
||||||
|
*
|
||||||
|
* @param props The constructor props for the `SidebarState` class.
|
||||||
|
* @returns The `SidebarState` instance.
|
||||||
|
*/
|
||||||
|
export function setSidebar(props: SidebarStateProps): SidebarState {
|
||||||
|
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the `SidebarState` instance from the context. This is a class instance,
|
||||||
|
* so you cannot destructure it.
|
||||||
|
* @returns The `SidebarState` instance.
|
||||||
|
*/
|
||||||
|
export function useSidebar(): SidebarState {
|
||||||
|
return getContext(Symbol.for(SYMBOL_KEY));
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { useSidebar } from "./context.svelte.js";
|
||||||
|
import Content from "./sidebar-content.svelte";
|
||||||
|
import Footer from "./sidebar-footer.svelte";
|
||||||
|
import GroupAction from "./sidebar-group-action.svelte";
|
||||||
|
import GroupContent from "./sidebar-group-content.svelte";
|
||||||
|
import GroupLabel from "./sidebar-group-label.svelte";
|
||||||
|
import Group from "./sidebar-group.svelte";
|
||||||
|
import Header from "./sidebar-header.svelte";
|
||||||
|
import Input from "./sidebar-input.svelte";
|
||||||
|
import Inset from "./sidebar-inset.svelte";
|
||||||
|
import MenuAction from "./sidebar-menu-action.svelte";
|
||||||
|
import MenuBadge from "./sidebar-menu-badge.svelte";
|
||||||
|
import MenuButton from "./sidebar-menu-button.svelte";
|
||||||
|
import MenuItem from "./sidebar-menu-item.svelte";
|
||||||
|
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
|
||||||
|
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
|
||||||
|
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
|
||||||
|
import MenuSub from "./sidebar-menu-sub.svelte";
|
||||||
|
import Menu from "./sidebar-menu.svelte";
|
||||||
|
import Provider from "./sidebar-provider.svelte";
|
||||||
|
import Rail from "./sidebar-rail.svelte";
|
||||||
|
import Separator from "./sidebar-separator.svelte";
|
||||||
|
import Trigger from "./sidebar-trigger.svelte";
|
||||||
|
import Root from "./sidebar.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Content,
|
||||||
|
Footer,
|
||||||
|
Group,
|
||||||
|
GroupAction,
|
||||||
|
GroupContent,
|
||||||
|
GroupLabel,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Inset,
|
||||||
|
Menu,
|
||||||
|
MenuAction,
|
||||||
|
MenuBadge,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuSkeleton,
|
||||||
|
MenuSub,
|
||||||
|
MenuSubButton,
|
||||||
|
MenuSubItem,
|
||||||
|
Provider,
|
||||||
|
Rail,
|
||||||
|
Root,
|
||||||
|
Separator,
|
||||||
|
//
|
||||||
|
Root as Sidebar,
|
||||||
|
Content as SidebarContent,
|
||||||
|
Footer as SidebarFooter,
|
||||||
|
Group as SidebarGroup,
|
||||||
|
GroupAction as SidebarGroupAction,
|
||||||
|
GroupContent as SidebarGroupContent,
|
||||||
|
GroupLabel as SidebarGroupLabel,
|
||||||
|
Header as SidebarHeader,
|
||||||
|
Input as SidebarInput,
|
||||||
|
Inset as SidebarInset,
|
||||||
|
Menu as SidebarMenu,
|
||||||
|
MenuAction as SidebarMenuAction,
|
||||||
|
MenuBadge as SidebarMenuBadge,
|
||||||
|
MenuButton as SidebarMenuButton,
|
||||||
|
MenuItem as SidebarMenuItem,
|
||||||
|
MenuSkeleton as SidebarMenuSkeleton,
|
||||||
|
MenuSub as SidebarMenuSub,
|
||||||
|
MenuSubButton as SidebarMenuSubButton,
|
||||||
|
MenuSubItem as SidebarMenuSubItem,
|
||||||
|
Provider as SidebarProvider,
|
||||||
|
Rail as SidebarRail,
|
||||||
|
Separator as SidebarSeparator,
|
||||||
|
Trigger as SidebarTrigger,
|
||||||
|
Trigger,
|
||||||
|
useSidebar,
|
||||||
|
};
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="content"
|
||||||
|
class={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="footer"
|
||||||
|
class={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLButtonAttributes> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const propObj = $derived({
|
||||||
|
class: cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
"data-sidebar": "group-action",
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: propObj })}
|
||||||
|
{:else}
|
||||||
|
<button bind:this={ref} {...propObj}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="group-content"
|
||||||
|
class={cn("w-full text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mergedProps = $derived({
|
||||||
|
class: cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
"data-sidebar": "group-label",
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<div bind:this={ref} {...mergedProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="group"
|
||||||
|
class={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="header"
|
||||||
|
class={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(""),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Input> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-sidebar="input"
|
||||||
|
class={cn(
|
||||||
|
"bg-background focus-visible:ring-sidebar-ring h-8 w-full shadow-none focus-visible:ring-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
"bg-background relative flex min-h-svh flex-1 flex-col",
|
||||||
|
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
showOnHover = false,
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLButtonAttributes> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
showOnHover?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mergedProps = $derived({
|
||||||
|
class: cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
"data-sidebar": "menu-action",
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<button bind:this={ref} {...mergedProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
class={cn(
|
||||||
|
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const sidebarMenuButtonVariants = tv({
|
||||||
|
base: "peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SidebarMenuButtonVariant = VariantProps<
|
||||||
|
typeof sidebarMenuButtonVariants
|
||||||
|
>["variant"];
|
||||||
|
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { mergeProps, type WithElementRef, type WithoutChildrenOrChild } from "bits-ui";
|
||||||
|
import type { ComponentProps, Snippet } from "svelte";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { useSidebar } from "./context.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
isActive = false,
|
||||||
|
tooltipContent,
|
||||||
|
tooltipContentProps,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
|
||||||
|
isActive?: boolean;
|
||||||
|
variant?: SidebarMenuButtonVariant;
|
||||||
|
size?: SidebarMenuButtonSize;
|
||||||
|
tooltipContent?: Snippet;
|
||||||
|
tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
const buttonProps = $derived({
|
||||||
|
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||||
|
"data-sidebar": "menu-button",
|
||||||
|
"data-size": size,
|
||||||
|
"data-active": isActive,
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Button({ props }: { props?: Record<string, unknown> })}
|
||||||
|
{@const mergedProps = mergeProps(buttonProps, props)}
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<button bind:this={ref} {...mergedProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !tooltipContent}
|
||||||
|
{@render Button({})}
|
||||||
|
{:else}
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
{@render Button({ props })}
|
||||||
|
{/snippet}
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
|
||||||
|
children={tooltipContent}
|
||||||
|
{...tooltipContentProps}
|
||||||
|
/>
|
||||||
|
</Tooltip.Root>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
class={cn("group/menu-item relative", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</li>
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
showIcon = false,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||||
|
showIcon?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Random width between 50% and 90%
|
||||||
|
const width = `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
class={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if showIcon}
|
||||||
|
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
|
||||||
|
{/if}
|
||||||
|
<Skeleton
|
||||||
|
class="h-4 max-w-[--skeleton-width] flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style="--skeleton-width: {width};"
|
||||||
|
/>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
class: className,
|
||||||
|
size = "md",
|
||||||
|
isActive,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
isActive?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mergedProps = $derived({
|
||||||
|
class: cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
"data-sidebar": "menu-sub-button",
|
||||||
|
"data-size": size,
|
||||||
|
"data-active": isActive,
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<a bind:this={ref} {...mergedProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li bind:this={ref} data-sidebar="menu-sub-item" {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</li>
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
class={cn(
|
||||||
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ul>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="menu"
|
||||||
|
class={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ul>
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import {
|
||||||
|
SIDEBAR_COOKIE_MAX_AGE,
|
||||||
|
SIDEBAR_COOKIE_NAME,
|
||||||
|
SIDEBAR_WIDTH,
|
||||||
|
SIDEBAR_WIDTH_ICON,
|
||||||
|
} from "./constants.js";
|
||||||
|
import { setSidebar } from "./context.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
open = $bindable(true),
|
||||||
|
onOpenChange = () => {},
|
||||||
|
class: className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sidebar = setSidebar({
|
||||||
|
open: () => open,
|
||||||
|
setOpen: (value: boolean) => {
|
||||||
|
open = value;
|
||||||
|
onOpenChange(value);
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
|
||||||
|
|
||||||
|
<Tooltip.Provider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
|
||||||
|
class={cn(
|
||||||
|
"group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:this={ref}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</Tooltip.Provider>
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { useSidebar } from "./context.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
|
||||||
|
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-sidebar="rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onclick={() => sidebar.toggle()}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
class={cn(
|
||||||
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||||
|
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Separator> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
bind:ref
|
||||||
|
data-sidebar="separator"
|
||||||
|
class={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import PanelLeft from "lucide-svelte/icons/panel-left";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { useSidebar } from "./context.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
onclick,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Button> & {
|
||||||
|
onclick?: (e: MouseEvent) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => {
|
||||||
|
onclick?.(e);
|
||||||
|
sidebar.toggle();
|
||||||
|
}}
|
||||||
|
data-sidebar="trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class={cn("h-7 w-7", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<PanelLeft />
|
||||||
|
<span class="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Sheet from "$lib/components/ui/sheet/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { WithElementRef } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { SIDEBAR_WIDTH_MOBILE } from "./constants.js";
|
||||||
|
import { useSidebar } from "./context.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
side?: "left" | "right";
|
||||||
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if collapsible === "none"}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:this={ref}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{:else if sidebar.isMobile}
|
||||||
|
<Sheet.Root
|
||||||
|
bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<Sheet.Content
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
class="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden"
|
||||||
|
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
|
||||||
|
{side}
|
||||||
|
>
|
||||||
|
<div class="flex h-full w-full flex-col">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</Sheet.Content>
|
||||||
|
</Sheet.Root>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class="text-sidebar-foreground group peer hidden md:block"
|
||||||
|
data-state={sidebar.state}
|
||||||
|
data-collapsible={sidebar.state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
>
|
||||||
|
<!-- This is what handles the sidebar gap on desktop -->
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./skeleton.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Skeleton,
|
||||||
|
};
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef, WithoutChildren } from "bits-ui";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("bg-muted animate-pulse rounded-md", className)}
|
||||||
|
{...restProps}
|
||||||
|
></div>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./slider.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Slider,
|
||||||
|
};
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Slider as SliderPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Discriminated Unions + Destructing (required for bindable) do not
|
||||||
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
|
-->
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
bind:value={value as never}
|
||||||
|
bind:ref
|
||||||
|
class={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ thumbs })}
|
||||||
|
<span class="bg-secondary relative h-2 w-full grow overflow-hidden rounded-full">
|
||||||
|
<SliderPrimitive.Range class="bg-primary absolute h-full" />
|
||||||
|
</span>
|
||||||
|
{#each thumbs as thumb}
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
index={thumb}
|
||||||
|
class="border-primary bg-background ring-offset-background focus-visible:ring-ring block size-5 rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as Toaster } from "./sonner.svelte";
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||||
|
import { mode } from "mode-watcher";
|
||||||
|
|
||||||
|
let restProps: SonnerProps = $props();
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
import Content from "./tooltip-content.svelte";
|
||||||
|
|
||||||
|
const Root = TooltipPrimitive.Root;
|
||||||
|
const Trigger = TooltipPrimitive.Trigger;
|
||||||
|
const Provider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Trigger,
|
||||||
|
Content,
|
||||||
|
Provider,
|
||||||
|
//
|
||||||
|
Root as Tooltip,
|
||||||
|
Content as TooltipContent,
|
||||||
|
Trigger as TooltipTrigger,
|
||||||
|
Provider as TooltipProvider,
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...restProps
|
||||||
|
}: TooltipPrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
// Make sure to install the 'pg' package
|
||||||
|
import { drizzle } from 'drizzle-orm/bun-sql'
|
||||||
|
import * as schema from './schema'
|
||||||
|
import { env } from '$env/dynamic/private'
|
||||||
|
|
||||||
|
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set')
|
||||||
|
const client = new Bun.SQL(env.DATABASE_URL)
|
||||||
|
|
||||||
|
export const db = drizzle(client, { schema })
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
bigint,
|
||||||
|
json,
|
||||||
|
integer,
|
||||||
|
} from 'drizzle-orm/pg-core'
|
||||||
|
import { relations, type InferSelectModel } from 'drizzle-orm'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
|
||||||
|
export const user = pgTable('user', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
email: varchar('email', { length: 255 }).notNull(),
|
||||||
|
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||||
|
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const session = pgTable('session', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
expiresAt: bigint('expires_at_epoch', { mode: 'number' }).notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const bybitWebhook = pgTable('bybit_webhook', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
direction: varchar('direction', { length: 255 })
|
||||||
|
.$type<'buy' | 'sell'>()
|
||||||
|
.notNull(),
|
||||||
|
symbol: varchar('symbol', { length: 255 }).notNull(),
|
||||||
|
quantityPercent: integer('quantity_percent').notNull(),
|
||||||
|
takeProfitPercent: integer('takeprofit_percent').notNull(),
|
||||||
|
stopLossPercent: integer('stoploss_percent').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Session = InferSelectModel<typeof session>
|
||||||
|
export type BybitWebhook = InferSelectModel<typeof bybitWebhook>
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { untrack } from "svelte";
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export class IsMobile {
|
||||||
|
#current = $state<boolean>(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
$effect(() => {
|
||||||
|
return untrack(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
const onChange = () => {
|
||||||
|
this.#current = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
};
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
onChange();
|
||||||
|
return () => {
|
||||||
|
mql.removeEventListener("change", onChange);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get current() {
|
||||||
|
return this.#current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import type { RequestEvent } from '@sveltejs/kit'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { sha256 } from '@oslojs/crypto/sha2'
|
||||||
|
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'
|
||||||
|
import { db } from '$lib/db'
|
||||||
|
import * as table from '$lib/db/schema'
|
||||||
|
|
||||||
|
const DAY_IN_MS = 1000 * 60 * 60 * 24
|
||||||
|
|
||||||
|
export const sessionCookieName = 'auth-session'
|
||||||
|
|
||||||
|
export function generateSessionToken() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(18))
|
||||||
|
const token = encodeBase64url(bytes)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(token: string, userId: string) {
|
||||||
|
const sessionId = encodeHexLowerCase(
|
||||||
|
sha256(new TextEncoder().encode(token)),
|
||||||
|
)
|
||||||
|
const session: table.Session = {
|
||||||
|
id: sessionId,
|
||||||
|
userId,
|
||||||
|
expiresAt: Date.now() + DAY_IN_MS * 30,
|
||||||
|
}
|
||||||
|
await db.insert(table.session).values(session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSessionToken(token: string) {
|
||||||
|
const sessionId = encodeHexLowerCase(
|
||||||
|
sha256(new TextEncoder().encode(token)),
|
||||||
|
)
|
||||||
|
const [result] = await db
|
||||||
|
.select({
|
||||||
|
// Adjust user table here to tweak returned data
|
||||||
|
user: table.user,
|
||||||
|
session: table.session,
|
||||||
|
})
|
||||||
|
.from(table.session)
|
||||||
|
.innerJoin(table.user, eq(table.session.userId, table.user.id))
|
||||||
|
.where(eq(table.session.id, sessionId))
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return { session: null, user: null }
|
||||||
|
}
|
||||||
|
const { session, user } = result
|
||||||
|
|
||||||
|
const sessionExpired = Date.now() >= session.expiresAt
|
||||||
|
if (sessionExpired) {
|
||||||
|
await db
|
||||||
|
.delete(table.session)
|
||||||
|
.where(eq(table.session.id, session.id))
|
||||||
|
return { session: null, user: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewSession =
|
||||||
|
Date.now() >= session.expiresAt - DAY_IN_MS * 15
|
||||||
|
if (renewSession) {
|
||||||
|
session.expiresAt = Date.now() + DAY_IN_MS * 30
|
||||||
|
await db
|
||||||
|
.update(table.session)
|
||||||
|
.set({ expiresAt: session.expiresAt })
|
||||||
|
.where(eq(table.session.id, session.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session, user }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionValidationResult = Awaited<
|
||||||
|
ReturnType<typeof validateSessionToken>
|
||||||
|
>
|
||||||
|
|
||||||
|
export async function invalidateSession(sessionId: string) {
|
||||||
|
await db
|
||||||
|
.delete(table.session)
|
||||||
|
.where(eq(table.session.id, sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionTokenCookie(
|
||||||
|
event: RequestEvent,
|
||||||
|
token: string,
|
||||||
|
expiresAt: Date,
|
||||||
|
) {
|
||||||
|
event.cookies.set(sessionCookieName, token, {
|
||||||
|
expires: expiresAt,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||||
|
event.cookies.delete(sessionCookieName, {
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { encodeBase32LowerCase } from '@oslojs/encoding'
|
||||||
|
|
||||||
|
export function generateId() {
|
||||||
|
// ID with 120 bits of entropy, or about the same as UUID v4.
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(15))
|
||||||
|
const id = encodeBase32LowerCase(bytes)
|
||||||
|
return id
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
export const chains = [
|
||||||
|
{ name: 'Ethereum', chainId: 1 },
|
||||||
|
{ name: 'Binance Smart Chain', chainId: 56 },
|
||||||
|
{ name: 'Polygon', chainId: 137 },
|
||||||
|
{ name: 'Arbirum One', chainId: 42161 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type Token = {
|
||||||
|
name: string
|
||||||
|
symbol: string
|
||||||
|
decimal: number
|
||||||
|
icon: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
export const tokens = {
|
||||||
|
1: [],
|
||||||
|
56: [
|
||||||
|
{
|
||||||
|
name: 'Binance-Peg Ethereum Token',
|
||||||
|
symbol: 'ETH',
|
||||||
|
decimal: 18,
|
||||||
|
icon: 'ETH.png',
|
||||||
|
address: '0x2170Ed0880ac9A755fd29B2688956BD959F933F8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Binance-Peg XRP Token',
|
||||||
|
symbol: 'XRP',
|
||||||
|
decimal: 18,
|
||||||
|
icon: 'XRP.png',
|
||||||
|
address: '0x1D2F0da169ceB9fC7B3144628dB156f3F6c60dBE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Binance-Peg BSC-USD',
|
||||||
|
symbol: 'BSC-USD',
|
||||||
|
decimal: 18,
|
||||||
|
icon: 'BSC-USD.png',
|
||||||
|
address: '0x55d398326f99059fF775485246999027B3197955',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Binance-Peg USD Coin',
|
||||||
|
symbol: 'USDC',
|
||||||
|
decimal: 18,
|
||||||
|
icon: 'USDC.png',
|
||||||
|
address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Thunder Wrapped BNB',
|
||||||
|
symbol: 'BNB',
|
||||||
|
decimal: 18,
|
||||||
|
icon: 'BNB.png',
|
||||||
|
address: '0x3E14602186DD9dE538F729547B3918D24c823546',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wrapped BNB',
|
||||||
|
symbol: 'WBNB',
|
||||||
|
decimal: 18,
|
||||||
|
icon: 'WBNB.png',
|
||||||
|
address: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Binance-Peg Dogecoin Token',
|
||||||
|
symbol: 'DOGE',
|
||||||
|
decimal: 8,
|
||||||
|
icon: 'DOGE.png',
|
||||||
|
address: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
137: [
|
||||||
|
{
|
||||||
|
name: 'USDC',
|
||||||
|
decimal: 6,
|
||||||
|
address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'USD Coin (PoS) (USDC.e)',
|
||||||
|
decimal: 6,
|
||||||
|
address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '(PoS) Tether USD (USDT)',
|
||||||
|
decimal: 6,
|
||||||
|
address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
42161: [],
|
||||||
|
} as const
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
redirect(302, '/dashboard/crypto-data')
|
||||||
|
return {}
|
||||||
|
}) satisfies PageServerLoad
|
||||||
@ -1,311 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Form from '$lib/components/ui/form'
|
|
||||||
import * as Select from '$lib/components/ui/select'
|
|
||||||
import { Input } from '$lib/components/ui/input'
|
|
||||||
import { superForm } from 'sveltekit-superforms'
|
|
||||||
import { zod } from 'sveltekit-superforms/adapters'
|
|
||||||
import { formSchema, intervals } from './schema'
|
|
||||||
import { categories } from './schema'
|
|
||||||
import { CalendarIcon } from 'lucide-svelte'
|
|
||||||
import { Calendar } from '$lib/components/ui/calendar/index.js'
|
|
||||||
import * as Popover from '$lib/components/ui/popover/index.js'
|
|
||||||
import * as Card from '$lib/components/ui/card/index.js'
|
|
||||||
import * as Tabs from '$lib/components/ui/tabs/index.js'
|
|
||||||
import {
|
|
||||||
CalendarDate,
|
|
||||||
DateFormatter,
|
|
||||||
type DateValue,
|
|
||||||
getLocalTimeZone,
|
|
||||||
parseDate,
|
|
||||||
today,
|
|
||||||
} from '@internationalized/date'
|
|
||||||
import { cn } from '$lib/utils.js'
|
|
||||||
import { buttonVariants } from '$lib/components/ui/button'
|
|
||||||
import Bingx from './(components)/bingx.svelte'
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
|
||||||
|
|
||||||
let { data } = $props()
|
|
||||||
|
|
||||||
const apiEndpoint = 'https://api.bybit.com/v5/market/kline'
|
|
||||||
const form = superForm(data.form, {
|
|
||||||
resetForm: false,
|
|
||||||
SPA: true,
|
|
||||||
validators: zod(formSchema),
|
|
||||||
onUpdated: async ({ form }) => {
|
|
||||||
if (!form.valid) return
|
|
||||||
|
|
||||||
console.log(form.data)
|
|
||||||
// Form validation
|
|
||||||
const url = new URL(apiEndpoint)
|
|
||||||
url.searchParams.set('symbol', form.data.symbol)
|
|
||||||
url.searchParams.set('interval', form.data.interval)
|
|
||||||
|
|
||||||
const start = new Date(
|
|
||||||
form.data.start + ' ' + form.data.startTime + ':00',
|
|
||||||
)
|
|
||||||
url.searchParams.set('start', start.getTime().toString())
|
|
||||||
|
|
||||||
const end = new Date(
|
|
||||||
form.data.end + ' ' + form.data.endTime + ':00',
|
|
||||||
)
|
|
||||||
|
|
||||||
url.searchParams.set('end', end.getTime().toString())
|
|
||||||
|
|
||||||
if (form.data.limit)
|
|
||||||
url.searchParams.set('limit', form.data.limit.toString())
|
|
||||||
|
|
||||||
if (form.data.category)
|
|
||||||
url.searchParams.set('category', form.data.category)
|
|
||||||
|
|
||||||
const response = await fetch(url)
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
console.log(data.result.list)
|
|
||||||
|
|
||||||
let csvContent =
|
|
||||||
'data:text/csv;charset=utf-8,' +
|
|
||||||
'startTime,openPrice,highPrice,lowPrice,closePrice,volume,turnover\n' +
|
|
||||||
data.result.list
|
|
||||||
.map((data: string[]) => {
|
|
||||||
let newData = data
|
|
||||||
newData[0] = new Date(parseInt(newData[0]))
|
|
||||||
.toLocaleString()
|
|
||||||
.replace(',', ' |')
|
|
||||||
return newData
|
|
||||||
})
|
|
||||||
.map((e: any) => e.join(','))
|
|
||||||
.join('\n')
|
|
||||||
|
|
||||||
var encodedUri = encodeURI(csvContent)
|
|
||||||
var link = document.createElement('a')
|
|
||||||
link.setAttribute('href', encodedUri)
|
|
||||||
link.setAttribute('download', 'data.csv')
|
|
||||||
document.body.appendChild(link)
|
|
||||||
|
|
||||||
link.click()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { form: formData, enhance } = form
|
|
||||||
|
|
||||||
const df = new DateFormatter('en-US', {
|
|
||||||
dateStyle: 'long',
|
|
||||||
})
|
|
||||||
|
|
||||||
let start = $state<DateValue | undefined>()
|
|
||||||
let end = $state<DateValue | undefined>()
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
start = $formData.start ? parseDate($formData.start) : undefined
|
|
||||||
end = $formData.end ? parseDate($formData.end) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
let placeholder = $state<DateValue>(today(getLocalTimeZone()))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Content>
|
|
||||||
<Tabs.Root value="bybit" class="w-[400px]">
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Trigger value="bybit">ByBit</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="bingx">BingX</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
<Tabs.Content value="bybit">
|
|
||||||
<form method="POST" use:enhance>
|
|
||||||
<Form.Field {form} name="symbol">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>Symbol</Form.Label>
|
|
||||||
<Input {...props} bind:value={$formData.symbol} />
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Description>Symbol</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="limit">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>Limit</Form.Label>
|
|
||||||
<Input
|
|
||||||
{...props}
|
|
||||||
bind:value={$formData.limit}
|
|
||||||
type="number" />
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Description>Limit</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="category">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>Category</Form.Label>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
bind:value={$formData.category}
|
|
||||||
name={props.name}>
|
|
||||||
<Select.Trigger {...props}>
|
|
||||||
{$formData.category ?? 'Select a category'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each categories as category}
|
|
||||||
<Select.Item
|
|
||||||
value={category}
|
|
||||||
label={category} />
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Description>Category</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="interval">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>Interval</Form.Label>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
bind:value={$formData.interval}
|
|
||||||
name={props.name}>
|
|
||||||
<Select.Trigger {...props}>
|
|
||||||
{$formData.interval ?? 'Select a interval'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each intervals as interval}
|
|
||||||
<Select.Item
|
|
||||||
value={interval}
|
|
||||||
label={interval} />
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Description>Interval</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="start" class="flex flex-col">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>Start Date</Form.Label>
|
|
||||||
<Popover.Root>
|
|
||||||
<Popover.Trigger
|
|
||||||
{...props}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: 'outline' }),
|
|
||||||
'w-[280px] justify-start pl-4 text-left font-normal',
|
|
||||||
!start && 'text-muted-foreground',
|
|
||||||
)}>
|
|
||||||
{start
|
|
||||||
? df.format(start.toDate(getLocalTimeZone()))
|
|
||||||
: 'Pick a date'}
|
|
||||||
<CalendarIcon
|
|
||||||
class="ml-auto size-4 opacity-50" />
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content class="w-auto p-0" side="top">
|
|
||||||
<Calendar
|
|
||||||
type="single"
|
|
||||||
value={start as DateValue}
|
|
||||||
bind:placeholder
|
|
||||||
minValue={new CalendarDate(1900, 1, 1)}
|
|
||||||
maxValue={today(getLocalTimeZone())}
|
|
||||||
calendarLabel="Date of birth"
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) {
|
|
||||||
$formData.start = v.toString()
|
|
||||||
} else {
|
|
||||||
$formData.start = ''
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
<Form.Description>Start Time</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
<input
|
|
||||||
hidden
|
|
||||||
value={$formData.start}
|
|
||||||
name={props.name} />
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="startTime">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>Start Time</Form.Label>
|
|
||||||
<Input
|
|
||||||
{...props}
|
|
||||||
bind:value={$formData.startTime}
|
|
||||||
type="time" />
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Description>Start Time</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="end" class="flex flex-col">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>End Date</Form.Label>
|
|
||||||
<Popover.Root>
|
|
||||||
<Popover.Trigger
|
|
||||||
{...props}
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: 'outline' }),
|
|
||||||
'w-[280px] justify-start pl-4 text-left font-normal',
|
|
||||||
!end && 'text-muted-foreground',
|
|
||||||
)}>
|
|
||||||
{end
|
|
||||||
? df.format(end.toDate(getLocalTimeZone()))
|
|
||||||
: 'Pick a date'}
|
|
||||||
<CalendarIcon
|
|
||||||
class="ml-auto size-4 opacity-50" />
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content class="w-auto p-0" side="top">
|
|
||||||
<Calendar
|
|
||||||
type="single"
|
|
||||||
value={end as DateValue}
|
|
||||||
bind:placeholder
|
|
||||||
minValue={new CalendarDate(1900, 1, 1)}
|
|
||||||
maxValue={today(getLocalTimeZone())}
|
|
||||||
calendarLabel="Date of birth"
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) {
|
|
||||||
$formData.end = v.toString()
|
|
||||||
} else {
|
|
||||||
$formData.end = ''
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
<Form.Description>End Date</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
<input
|
|
||||||
hidden
|
|
||||||
value={$formData.end}
|
|
||||||
name={props.name} />
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field {form} name="endTime">
|
|
||||||
<Form.Control>
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Form.Label>End Time</Form.Label>
|
|
||||||
<Input
|
|
||||||
{...props}
|
|
||||||
bind:value={$formData.endTime}
|
|
||||||
type="time" />
|
|
||||||
{/snippet}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Description>End Time</Form.Description>
|
|
||||||
<Form.FieldErrors />
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Button>Submit</Form.Button>
|
|
||||||
</form>
|
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="bingx">
|
|
||||||
<Bingx />
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs.Root>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { json } from '@sveltejs/kit'
|
||||||
|
import { RestClientV5 } from 'bybit-api'
|
||||||
|
|
||||||
|
const apiKey = 'wrc1w54Zp5JAfXLxY2'
|
||||||
|
const apiSecret = 'tY7oYPhwSE1gabFS4PmxtmbDOhkYWvPh0khf'
|
||||||
|
|
||||||
|
const client = new RestClientV5({
|
||||||
|
key: apiKey,
|
||||||
|
secret: apiSecret,
|
||||||
|
demoTrading: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GET = async (event) => {
|
||||||
|
const response = await client.getWalletBalance({
|
||||||
|
accountType: 'UNIFIED',
|
||||||
|
})
|
||||||
|
return json({ response })
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { db } from '$lib/db/index.js'
|
||||||
|
import { bybitWebhook } from '$lib/db/schema.js'
|
||||||
|
import { generateId } from '$lib/server/utils.js'
|
||||||
|
import { json } from '@sveltejs/kit'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const GET = async ({ locals }) => {
|
||||||
|
const webhooks = await db
|
||||||
|
.select()
|
||||||
|
.from(bybitWebhook)
|
||||||
|
.where(eq(bybitWebhook.userId, locals.user.id))
|
||||||
|
|
||||||
|
return json({ webhooks })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
symbol: z.string(),
|
||||||
|
side: z.enum(['buy', 'sell']),
|
||||||
|
qty: z.number().refine((value) => value.toString()),
|
||||||
|
takeProfit: z.number().refine((value) => value.toString()),
|
||||||
|
stopLoss: z.number().refine((value) => value.toString()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = async ({ locals, request }) => {
|
||||||
|
const form = formSchema.safeParse(await request.json())
|
||||||
|
|
||||||
|
if (!form.success) {
|
||||||
|
return json({}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(form.data)
|
||||||
|
|
||||||
|
const { qty, side, stopLoss, symbol, takeProfit } = form.data
|
||||||
|
|
||||||
|
const existingWebhook = await db.query.bybitWebhook.findFirst({
|
||||||
|
where: (webhook, { eq, and }) =>
|
||||||
|
and(
|
||||||
|
eq(webhook.userId, locals.user.id),
|
||||||
|
eq(webhook.symbol, symbol),
|
||||||
|
eq(webhook.direction, side),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingWebhook) {
|
||||||
|
return json({ message: 'Webhook exist' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(bybitWebhook).values({
|
||||||
|
id: generateId(),
|
||||||
|
direction: side,
|
||||||
|
quantityPercent: qty,
|
||||||
|
stopLossPercent: stopLoss,
|
||||||
|
symbol: symbol,
|
||||||
|
takeProfitPercent: takeProfit,
|
||||||
|
userId: locals.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return json({})
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js'
|
||||||
|
import type { ComponentProps } from 'svelte'
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { CoinsIcon, HomeIcon, SquareTerminal } from 'lucide-svelte'
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
collapsible = 'icon',
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Sidebar.Root> & {} = $props()
|
||||||
|
|
||||||
|
const data = $derived({
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: 'Crypto Data',
|
||||||
|
url: `/dashboard/crypto-data`,
|
||||||
|
icon: HomeIcon,
|
||||||
|
isActive: page.url.pathname === `/dashboard/crypto-data`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Arbitrage',
|
||||||
|
url: `/dashboard/swap`,
|
||||||
|
icon: SquareTerminal,
|
||||||
|
isActive: page.url.pathname === `/dashboard/swap`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ByBit',
|
||||||
|
url: `/dashboard/bybit`,
|
||||||
|
icon: CoinsIcon,
|
||||||
|
isActive: page.url.pathname === `/dashboard/bybit`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Root bind:ref {collapsible} {...restProps}>
|
||||||
|
<Sidebar.Content>
|
||||||
|
<Sidebar.Group>
|
||||||
|
<Sidebar.GroupLabel>Dashboard</Sidebar.GroupLabel>
|
||||||
|
<Sidebar.Menu>
|
||||||
|
{#each data.navMain as item (item.title)}
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton isActive={item.isActive}>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href={item.url} {...props}>
|
||||||
|
{#if item.icon}
|
||||||
|
<item.icon class="h-8" />
|
||||||
|
{/if}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.Group>
|
||||||
|
</Sidebar.Content>
|
||||||
|
<Sidebar.Rail />
|
||||||
|
</Sidebar.Root>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AppSidebar from './(components)/app-sidebar.svelte'
|
||||||
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js'
|
||||||
|
|
||||||
|
let { data, children } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Provider>
|
||||||
|
<AppSidebar collapsible="offcanvas" />
|
||||||
|
<Sidebar.Inset>
|
||||||
|
<header class="flex h-16 shrink-0 items-center gap-2 px-4">
|
||||||
|
<Sidebar.Trigger class="-ml-1" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
</Sidebar.Inset>
|
||||||
|
</Sidebar.Provider>
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { WebsocketClient } from 'bybit-api'
|
||||||
|
|
||||||
|
import * as Form from '$lib/components/ui/form'
|
||||||
|
import * as Select from '$lib/components/ui/select'
|
||||||
|
import { Input } from '$lib/components/ui/input'
|
||||||
|
import { superForm } from 'sveltekit-superforms'
|
||||||
|
import { zod } from 'sveltekit-superforms/adapters'
|
||||||
|
import { formSchema, categories } from './schema'
|
||||||
|
import { CalendarIcon } from 'lucide-svelte'
|
||||||
|
import { Calendar } from '$lib/components/ui/calendar/index.js'
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js'
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js'
|
||||||
|
import { Slider } from '$lib/components/ui/slider/index.js'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import type { KLineWSResponse } from './types'
|
||||||
|
import { Label } from '$lib/components/ui/label'
|
||||||
|
import { customFetch } from '$lib/client/fetch'
|
||||||
|
import type { BybitWebhook } from '$lib/db/schema'
|
||||||
|
|
||||||
|
let { data } = $props()
|
||||||
|
|
||||||
|
const form = superForm(data.form, {
|
||||||
|
resetForm: false,
|
||||||
|
SPA: true,
|
||||||
|
validators: zod(formSchema),
|
||||||
|
onUpdate: async ({ form, result }) => {
|
||||||
|
if (result.type == 'success') {
|
||||||
|
console.log(result.data)
|
||||||
|
const response = await customFetch('/api/bybit/webhook', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(result.data.form.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
webhooks = await getWebhooks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getWebhooks = async () => {
|
||||||
|
const response = await customFetch('/api/bybit/webhook')
|
||||||
|
|
||||||
|
return (await response.json()).webhooks as BybitWebhook[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let webhooks = $state<BybitWebhook[]>([])
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
webhooks = await getWebhooks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiKey = 'wrc1w54Zp5JAfXLxY2'
|
||||||
|
const apiSecret = 'tY7oYPhwSE1gabFS4PmxtmbDOhkYWvPh0khf'
|
||||||
|
|
||||||
|
let price = $state<number>()
|
||||||
|
|
||||||
|
const handleKLineData = (data: KLineWSResponse) => {
|
||||||
|
price = Number(data.data[0].close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPublicWS = () => {
|
||||||
|
const client = new WebsocketClient({
|
||||||
|
market: 'v5',
|
||||||
|
key: apiKey,
|
||||||
|
secret: apiSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('error', (data) => {
|
||||||
|
console.error('ws exception: ', data)
|
||||||
|
})
|
||||||
|
|
||||||
|
client.subscribeV5(['kline.1.BTCUSDT'], 'spot')
|
||||||
|
|
||||||
|
client.on('open', function () {
|
||||||
|
console.log('connection open')
|
||||||
|
})
|
||||||
|
client.on('update', function (message) {
|
||||||
|
// console.log('update', message)
|
||||||
|
if (message.topic === 'kline.1.BTCUSDT') {
|
||||||
|
handleKLineData(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
client.on('response', function (response) {
|
||||||
|
console.log('response', response)
|
||||||
|
})
|
||||||
|
client.on('close', () => {
|
||||||
|
console.log('connection closed')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPrivateWS = () => {
|
||||||
|
const client = new WebsocketClient({
|
||||||
|
market: 'v5',
|
||||||
|
key: apiKey,
|
||||||
|
secret: apiSecret,
|
||||||
|
demoTrading: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('error', (data) => {
|
||||||
|
console.error('ws exception: ', data)
|
||||||
|
})
|
||||||
|
|
||||||
|
client.subscribeV5(['order', 'wallet', 'greeks'], 'linear')
|
||||||
|
|
||||||
|
client.on('open', function () {
|
||||||
|
console.log('connection open')
|
||||||
|
})
|
||||||
|
client.on('update', function (message) {
|
||||||
|
console.log('update', message)
|
||||||
|
})
|
||||||
|
client.on('response', function (response) {
|
||||||
|
console.log('response', response)
|
||||||
|
})
|
||||||
|
client.on('close', () => {
|
||||||
|
console.log('connection closed')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// createPublicWS()
|
||||||
|
// createPrivateWS()
|
||||||
|
const response = await fetch('/api/bybit/wallet')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { form: formData, enhance } = form
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col items-center justify-center">
|
||||||
|
<form method="POST" use:enhance class="grid grid-cols-2 gap-4">
|
||||||
|
<Form.Field {form} name="symbol">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Symbol</Form.Label>
|
||||||
|
<Input {...props} bind:value={$formData.symbol} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Symbol</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="side">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Side</Form.Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={$formData.side}
|
||||||
|
name={props.name}>
|
||||||
|
<Select.Trigger {...props}>
|
||||||
|
{$formData.side ?? 'Select a side'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value={'Buy'} label={'Buy'} />
|
||||||
|
<Select.Item value={'Sell'} label={'Sell'} />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Side</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="qty">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Quantity (%)</Form.Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...props}
|
||||||
|
bind:value={$formData.qty} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Quantity</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
<Slider
|
||||||
|
type="single"
|
||||||
|
bind:value={$formData.qty}
|
||||||
|
max={100}
|
||||||
|
step={1} />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="takeProfit">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Take Profit (%)</Form.Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...props}
|
||||||
|
bind:value={$formData.takeProfit} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Take Profit</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="stopLoss">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Stop Loss (%)</Form.Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...props}
|
||||||
|
bind:value={$formData.stopLoss} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Stop Loss</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<Form.Button>Submit</Form.Button>
|
||||||
|
</form>
|
||||||
|
<div>
|
||||||
|
<div>Webhooks</div>
|
||||||
|
{#each webhooks as webhook}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content>
|
||||||
|
<div>ID: {webhook.id}</div>
|
||||||
|
<div>Symbol: {webhook.symbol}</div>
|
||||||
|
<div>Direction: {webhook.direction}</div>
|
||||||
|
<div>Qty: {webhook.quantityPercent}</div>
|
||||||
|
<div>Stop Loss (%): {webhook.stopLossPercent}</div>
|
||||||
|
<div>Take Profit (%): {webhook.takeProfitPercent}</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { superValidate } from 'sveltekit-superforms'
|
||||||
|
import { zod } from 'sveltekit-superforms/adapters'
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import { formSchema } from './schema'
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
const form = await superValidate(
|
||||||
|
{
|
||||||
|
symbol: 'BTCUSDT',
|
||||||
|
},
|
||||||
|
zod(formSchema),
|
||||||
|
{ errors: false },
|
||||||
|
)
|
||||||
|
return { form }
|
||||||
|
}) satisfies PageLoad
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export type Category = 'spot' | 'linear' | 'inverse'
|
||||||
|
|
||||||
|
export const categories: Category[] = ['linear', 'spot', 'inverse']
|
||||||
|
|
||||||
|
export const formSchema = z.object({
|
||||||
|
symbol: z.string(),
|
||||||
|
side: z.enum(['buy', 'sell']),
|
||||||
|
qty: z.number().refine((value) => value.toString()),
|
||||||
|
takeProfit: z.number().refine((value) => value.toString()),
|
||||||
|
stopLoss: z.number().refine((value) => value.toString()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FormSchema = typeof formSchema
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
export type KLineWSResponse = {
|
||||||
|
topic: string // "kline.1.BTCUSDT"
|
||||||
|
data: {
|
||||||
|
close: string // '97312.42'
|
||||||
|
confirm: boolean // true
|
||||||
|
end: number // 1738907339999
|
||||||
|
high: string // '97325.67'
|
||||||
|
interval: string // '1'
|
||||||
|
low: string // '97283.66'
|
||||||
|
open: string // '97301.99'
|
||||||
|
start: number // 1738907280000
|
||||||
|
timestamp: number // 1738907340143
|
||||||
|
turnover: string // '1231841.67604775'
|
||||||
|
volume: string // '12.65954'
|
||||||
|
}[]
|
||||||
|
ts: number // 1738907340143
|
||||||
|
type: 'snapshot'
|
||||||
|
wsKey: 'v5SpotPublic'
|
||||||
|
}
|
||||||
@ -0,0 +1,315 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Form from '$lib/components/ui/form'
|
||||||
|
import * as Select from '$lib/components/ui/select'
|
||||||
|
import { Input } from '$lib/components/ui/input'
|
||||||
|
import { superForm } from 'sveltekit-superforms'
|
||||||
|
import { zod } from 'sveltekit-superforms/adapters'
|
||||||
|
import { formSchema, intervals } from './schema'
|
||||||
|
import { categories } from './schema'
|
||||||
|
import { CalendarIcon } from 'lucide-svelte'
|
||||||
|
import { Calendar } from '$lib/components/ui/calendar/index.js'
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js'
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js'
|
||||||
|
import * as Tabs from '$lib/components/ui/tabs/index.js'
|
||||||
|
import {
|
||||||
|
CalendarDate,
|
||||||
|
DateFormatter,
|
||||||
|
type DateValue,
|
||||||
|
getLocalTimeZone,
|
||||||
|
parseDate,
|
||||||
|
today,
|
||||||
|
} from '@internationalized/date'
|
||||||
|
import { cn } from '$lib/utils.js'
|
||||||
|
import { buttonVariants } from '$lib/components/ui/button'
|
||||||
|
import Bingx from './(components)/bingx.svelte'
|
||||||
|
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||||
|
|
||||||
|
let { data } = $props()
|
||||||
|
|
||||||
|
const apiEndpoint = 'https://api.bybit.com/v5/market/kline'
|
||||||
|
const form = superForm(data.form, {
|
||||||
|
resetForm: false,
|
||||||
|
SPA: true,
|
||||||
|
validators: zod(formSchema),
|
||||||
|
onUpdated: async ({ form }) => {
|
||||||
|
if (!form.valid) return
|
||||||
|
|
||||||
|
console.log(form.data)
|
||||||
|
// Form validation
|
||||||
|
const url = new URL(apiEndpoint)
|
||||||
|
url.searchParams.set('symbol', form.data.symbol)
|
||||||
|
url.searchParams.set('interval', form.data.interval)
|
||||||
|
|
||||||
|
const start = new Date(
|
||||||
|
form.data.start + ' ' + form.data.startTime + ':00',
|
||||||
|
)
|
||||||
|
url.searchParams.set('start', start.getTime().toString())
|
||||||
|
|
||||||
|
const end = new Date(
|
||||||
|
form.data.end + ' ' + form.data.endTime + ':00',
|
||||||
|
)
|
||||||
|
|
||||||
|
url.searchParams.set('end', end.getTime().toString())
|
||||||
|
|
||||||
|
if (form.data.limit)
|
||||||
|
url.searchParams.set('limit', form.data.limit.toString())
|
||||||
|
|
||||||
|
if (form.data.category)
|
||||||
|
url.searchParams.set('category', form.data.category)
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
console.log(data.result.list)
|
||||||
|
|
||||||
|
let csvContent =
|
||||||
|
'data:text/csv;charset=utf-8,' +
|
||||||
|
'startTime,openPrice,highPrice,lowPrice,closePrice,volume,turnover\n' +
|
||||||
|
data.result.list
|
||||||
|
.map((data: string[]) => {
|
||||||
|
let newData = data
|
||||||
|
newData[0] = new Date(parseInt(newData[0]))
|
||||||
|
.toLocaleString()
|
||||||
|
.replace(',', ' |')
|
||||||
|
return newData
|
||||||
|
})
|
||||||
|
.map((e: any) => e.join(','))
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
var encodedUri = encodeURI(csvContent)
|
||||||
|
var link = document.createElement('a')
|
||||||
|
link.setAttribute('href', encodedUri)
|
||||||
|
link.setAttribute('download', 'data.csv')
|
||||||
|
document.body.appendChild(link)
|
||||||
|
|
||||||
|
link.click()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { form: formData, enhance } = form
|
||||||
|
|
||||||
|
const df = new DateFormatter('en-US', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
})
|
||||||
|
|
||||||
|
let start = $state<DateValue | undefined>()
|
||||||
|
let end = $state<DateValue | undefined>()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
start = $formData.start ? parseDate($formData.start) : undefined
|
||||||
|
end = $formData.end ? parseDate($formData.end) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
let placeholder = $state<DateValue>(today(getLocalTimeZone()))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollArea class="max-h-[calc(100vh-64px)] flex-1">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content>
|
||||||
|
<Tabs.Root value="bybit" class="w-[400px]">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Trigger value="bybit">ByBit</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="bingx">BingX</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="bybit">
|
||||||
|
<form method="POST" use:enhance>
|
||||||
|
<Form.Field {form} name="symbol">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Symbol</Form.Label>
|
||||||
|
<Input {...props} bind:value={$formData.symbol} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Symbol</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="limit">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Limit</Form.Label>
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
bind:value={$formData.limit}
|
||||||
|
type="number" />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Limit</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="category">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Category</Form.Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={$formData.category}
|
||||||
|
name={props.name}>
|
||||||
|
<Select.Trigger {...props}>
|
||||||
|
{$formData.category ?? 'Select a category'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each categories as category}
|
||||||
|
<Select.Item
|
||||||
|
value={category}
|
||||||
|
label={category} />
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Category</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="interval">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Interval</Form.Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
bind:value={$formData.interval}
|
||||||
|
name={props.name}>
|
||||||
|
<Select.Trigger {...props}>
|
||||||
|
{$formData.interval ?? 'Select a interval'}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each intervals as interval}
|
||||||
|
<Select.Item
|
||||||
|
value={interval}
|
||||||
|
label={interval} />
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Interval</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="start" class="flex flex-col">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Start Date</Form.Label>
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger
|
||||||
|
{...props}
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'w-[280px] justify-start pl-4 text-left font-normal',
|
||||||
|
!start && 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{start
|
||||||
|
? df.format(
|
||||||
|
start.toDate(getLocalTimeZone()),
|
||||||
|
)
|
||||||
|
: 'Pick a date'}
|
||||||
|
<CalendarIcon
|
||||||
|
class="ml-auto size-4 opacity-50" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-auto p-0" side="top">
|
||||||
|
<Calendar
|
||||||
|
type="single"
|
||||||
|
value={start as DateValue}
|
||||||
|
bind:placeholder
|
||||||
|
minValue={new CalendarDate(1900, 1, 1)}
|
||||||
|
maxValue={today(getLocalTimeZone())}
|
||||||
|
calendarLabel="Date of birth"
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
$formData.start = v.toString()
|
||||||
|
} else {
|
||||||
|
$formData.start = ''
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
<Form.Description>Start Time</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
value={$formData.start}
|
||||||
|
name={props.name} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="startTime">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>Start Time</Form.Label>
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
bind:value={$formData.startTime}
|
||||||
|
type="time" />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>Start Time</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="end" class="flex flex-col">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>End Date</Form.Label>
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger
|
||||||
|
{...props}
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'w-[280px] justify-start pl-4 text-left font-normal',
|
||||||
|
!end && 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{end
|
||||||
|
? df.format(end.toDate(getLocalTimeZone()))
|
||||||
|
: 'Pick a date'}
|
||||||
|
<CalendarIcon
|
||||||
|
class="ml-auto size-4 opacity-50" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-auto p-0" side="top">
|
||||||
|
<Calendar
|
||||||
|
type="single"
|
||||||
|
value={end as DateValue}
|
||||||
|
bind:placeholder
|
||||||
|
minValue={new CalendarDate(1900, 1, 1)}
|
||||||
|
maxValue={today(getLocalTimeZone())}
|
||||||
|
calendarLabel="Date of birth"
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
$formData.end = v.toString()
|
||||||
|
} else {
|
||||||
|
$formData.end = ''
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
<Form.Description>End Date</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
value={$formData.end}
|
||||||
|
name={props.name} />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="endTime">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>End Time</Form.Label>
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
bind:value={$formData.endTime}
|
||||||
|
type="time" />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>End Time</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Button>Submit</Form.Button>
|
||||||
|
</form>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="bingx">
|
||||||
|
<Bingx />
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const getQuoteByReceivingAmount = async (
|
||||||
|
fromChain: string,
|
||||||
|
toChain: string,
|
||||||
|
fromToken: string,
|
||||||
|
toToken: string,
|
||||||
|
toAmount: string,
|
||||||
|
fromAddress: string,
|
||||||
|
) => {
|
||||||
|
const result = await fetch('https://li.quest/v1/quote/toAmount', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
params: {
|
||||||
|
fromChain,
|
||||||
|
toChain,
|
||||||
|
fromToken,
|
||||||
|
toToken,
|
||||||
|
toAmount,
|
||||||
|
fromAddress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return await result.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromChain = 'DAI'
|
||||||
|
const fromToken = 'USDC'
|
||||||
|
const toChain = 'POL'
|
||||||
|
const toToken = 'USDC'
|
||||||
|
const toAmount = '1000000' // 1 USDC = 1000000
|
||||||
|
const fromAddress = YOUR_WALLET_ADDRESS
|
||||||
|
</script>
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Token } from '$lib/tokens'
|
||||||
|
import { Sifi } from '@sifi/sdk'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
import * as Card from '$lib/components/ui/card'
|
||||||
|
|
||||||
|
const sifi = new Sifi()
|
||||||
|
|
||||||
|
let {
|
||||||
|
fromToken,
|
||||||
|
toToken,
|
||||||
|
fromAmount,
|
||||||
|
fromChain,
|
||||||
|
}: {
|
||||||
|
fromToken: Token
|
||||||
|
toToken: Token
|
||||||
|
fromAmount: number
|
||||||
|
fromChain: number
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
// Get a quote to swap 100 USDC to WETH
|
||||||
|
let quote = $state(
|
||||||
|
sifi.getQuote({
|
||||||
|
fromToken: fromToken.address,
|
||||||
|
toToken: toToken.address,
|
||||||
|
fromAmount: BigInt(
|
||||||
|
fromAmount * Math.pow(10, fromToken.decimal),
|
||||||
|
),
|
||||||
|
fromChain: fromChain,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
quote = sifi.getQuote({
|
||||||
|
fromToken: fromToken.address,
|
||||||
|
toToken: toToken.address,
|
||||||
|
fromAmount: BigInt(
|
||||||
|
fromAmount * Math.pow(10, fromToken.decimal),
|
||||||
|
),
|
||||||
|
fromChain: fromChain,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// setInterval(() => {
|
||||||
|
// }, 10000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content>
|
||||||
|
{#await quote then quote}
|
||||||
|
<!-- {JSON.stringify(
|
||||||
|
quote,
|
||||||
|
(key, value) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value, // return everything else unchanged
|
||||||
|
)} -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
From Amount: {Number(quote.fromAmount) /
|
||||||
|
Math.pow(10, fromToken.decimal)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
To Amount: {Number(quote.toAmount) /
|
||||||
|
Math.pow(10, toToken.decimal)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Estimated Gas: {quote.estimatedGas}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Estimated Gas USD: {quote.source.quote.gasCostUSD}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
const uniswapSubgraphUrl =
|
||||||
|
'https://gateway.thegraph.com/api/16de87701a9aee18e744b6cc8e7124d5/subgraphs/id/F85MNzUGYqgSHSHRGgeVMNsdnW1KtZSVgFULumXRZTw2'
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
{
|
||||||
|
pools (orderBy: totalValueLockedETH, orderDirection: desc) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const fetchPoolsData = async () => {
|
||||||
|
await fetch(uniswapSubgraphUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchPoolsData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Avatar from '$lib/components/ui/avatar/index.js'
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js'
|
||||||
|
import * as Command from '$lib/components/ui/command/index.js'
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js'
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js'
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js'
|
||||||
|
|
||||||
|
import Sifi from './(components)/sifi.svelte'
|
||||||
|
import { ArrowDownUpIcon } from 'lucide-svelte'
|
||||||
|
import { tokens, type Token } from '$lib/tokens'
|
||||||
|
import Uniswap from './(components)/uniswap.svelte'
|
||||||
|
|
||||||
|
let { data } = $props()
|
||||||
|
|
||||||
|
let changeTokenDialogOpen = $state(false)
|
||||||
|
let changeTokenMode = $state<'from' | 'to'>('from')
|
||||||
|
|
||||||
|
let fromToken = $state<Token>(tokens['56'][0])
|
||||||
|
let fromAmount = $state('')
|
||||||
|
|
||||||
|
let toToken = $state<Token>(tokens[56][1])
|
||||||
|
let toAmount = $state('')
|
||||||
|
|
||||||
|
const regexp = /^\d*\.?\d*$/
|
||||||
|
|
||||||
|
const swapInput = () => {
|
||||||
|
const tempToken = fromToken
|
||||||
|
const tempAmount = fromAmount
|
||||||
|
|
||||||
|
fromToken = toToken
|
||||||
|
fromAmount = toAmount
|
||||||
|
|
||||||
|
toToken = tempToken
|
||||||
|
toAmount = tempAmount
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-1 flex-col items-center justify-center gap-4 p-4">
|
||||||
|
<!-- <Sifi /> -->
|
||||||
|
<Uniswap />
|
||||||
|
<Card.Root class="w-full max-w-[500px]">
|
||||||
|
<Card.Content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p>From</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="0.0"
|
||||||
|
bind:value={() => fromAmount,
|
||||||
|
(value) => {
|
||||||
|
const valid = regexp.test(value)
|
||||||
|
if (valid) {
|
||||||
|
fromAmount = value
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
changeTokenMode = 'from'
|
||||||
|
changeTokenDialogOpen = true
|
||||||
|
}}>
|
||||||
|
<Avatar.Root class="size-6">
|
||||||
|
<Avatar.Image
|
||||||
|
src={`/${fromToken.icon}`}
|
||||||
|
alt={fromToken.icon} />
|
||||||
|
<Avatar.Fallback>{fromToken.symbol}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
{fromToken.symbol}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="rounded-full"
|
||||||
|
onclick={swapInput}>
|
||||||
|
<ArrowDownUpIcon />
|
||||||
|
</Button>
|
||||||
|
<Card.Root class="w-full max-w-[500px]">
|
||||||
|
<Card.Content>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p>To</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<Input
|
||||||
|
disabled
|
||||||
|
placeholder="0.0"
|
||||||
|
bind:value={() => toAmount,
|
||||||
|
(value) => {
|
||||||
|
const valid = regexp.test(value)
|
||||||
|
if (valid) {
|
||||||
|
toAmount = value
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
changeTokenMode = 'to'
|
||||||
|
changeTokenDialogOpen = true
|
||||||
|
}}>
|
||||||
|
<Avatar.Root class="size-6">
|
||||||
|
<Avatar.Image
|
||||||
|
src={`/${toToken.icon}`}
|
||||||
|
alt={toToken.icon} />
|
||||||
|
<Avatar.Fallback>{toToken.symbol}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
{toToken.symbol}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{#if parseFloat(fromAmount) > 0 && fromToken && toToken}
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<Sifi
|
||||||
|
fromAmount={parseFloat(fromAmount)}
|
||||||
|
fromChain={56}
|
||||||
|
{fromToken}
|
||||||
|
{toToken} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={changeTokenDialogOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Command.Root>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Command.Input placeholder="Search A Token" />
|
||||||
|
</Dialog.Header>
|
||||||
|
<Command.List>
|
||||||
|
{#each tokens['56'] as token}
|
||||||
|
<Command.Item
|
||||||
|
onclick={() => {
|
||||||
|
if (changeTokenMode === 'from') {
|
||||||
|
const temp = token
|
||||||
|
if (toToken.symbol === token.symbol) {
|
||||||
|
toToken = fromToken
|
||||||
|
}
|
||||||
|
fromToken = temp
|
||||||
|
}
|
||||||
|
if (changeTokenMode === 'to') {
|
||||||
|
const temp = token
|
||||||
|
if (fromToken.symbol === token.symbol) {
|
||||||
|
fromToken = toToken
|
||||||
|
}
|
||||||
|
toToken = temp
|
||||||
|
}
|
||||||
|
changeTokenDialogOpen = false
|
||||||
|
}}>
|
||||||
|
<Avatar.Root class="size-6">
|
||||||
|
<Avatar.Image src={`/${token.icon}`} alt={token.icon} />
|
||||||
|
<Avatar.Fallback>{token.symbol}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
{token.name} ({token.symbol})
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.List>
|
||||||
|
</Command.Root>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
import { hash, verify } from '@node-rs/argon2'
|
||||||
|
import { encodeBase32LowerCase } from '@oslojs/encoding'
|
||||||
|
import { fail, redirect } from '@sveltejs/kit'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import * as auth from '$lib/server/auth'
|
||||||
|
import { db } from '$lib/db'
|
||||||
|
import * as table from '$lib/db/schema'
|
||||||
|
import type { Actions, PageServerLoad } from './$types'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (event.locals.user) {
|
||||||
|
return redirect(302, '/dashboard')
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
login: async (event) => {
|
||||||
|
const formData = await event.request.formData()
|
||||||
|
const email = formData.get('email')
|
||||||
|
const password = formData.get('password')
|
||||||
|
|
||||||
|
const form = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
})
|
||||||
|
.safeParse({ email: email, password })
|
||||||
|
|
||||||
|
if (!form.success) {
|
||||||
|
return fail(400, { message: 'Invalid Credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(table.user)
|
||||||
|
.where(eq(table.user.email, form.data.email))
|
||||||
|
|
||||||
|
const existingUser = results.at(0)
|
||||||
|
if (!existingUser) {
|
||||||
|
return fail(400, { message: 'Incorrect username or password' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await Bun.password.verify(
|
||||||
|
form.data.password,
|
||||||
|
existingUser.passwordHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validPassword) {
|
||||||
|
return fail(400, { message: 'Incorrect username or password' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = auth.generateSessionToken()
|
||||||
|
const session = await auth.createSession(
|
||||||
|
sessionToken,
|
||||||
|
existingUser.id,
|
||||||
|
)
|
||||||
|
auth.setSessionTokenCookie(
|
||||||
|
event,
|
||||||
|
sessionToken,
|
||||||
|
new Date(session.expiresAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(302, '/dashboard')
|
||||||
|
},
|
||||||
|
register: async (event) => {
|
||||||
|
const formData = await event.request.formData()
|
||||||
|
const email = formData.get('email')
|
||||||
|
const password = formData.get('password')
|
||||||
|
|
||||||
|
const form = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
})
|
||||||
|
.safeParse({ email: email, password })
|
||||||
|
|
||||||
|
if (!form.success) {
|
||||||
|
return fail(400, { message: 'Invalid Credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = generateUserId()
|
||||||
|
const passwordHash = await Bun.password.hash(form.data.password)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await db.query.user.findFirst({
|
||||||
|
where: (user, { eq }) => eq(user.email, form.data.email),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const validPassword = await Bun.password.verify(
|
||||||
|
form.data.password,
|
||||||
|
passwordHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validPassword) {
|
||||||
|
return fail(400, {
|
||||||
|
message: 'Incorrect username or password',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = auth.generateSessionToken()
|
||||||
|
const session = await auth.createSession(
|
||||||
|
sessionToken,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
auth.setSessionTokenCookie(
|
||||||
|
event,
|
||||||
|
sessionToken,
|
||||||
|
new Date(session.expiresAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(302, '/dashboard')
|
||||||
|
}
|
||||||
|
await db.insert(table.user).values({
|
||||||
|
id: userId,
|
||||||
|
email: form.data.email,
|
||||||
|
passwordHash,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionToken = auth.generateSessionToken()
|
||||||
|
const session = await auth.createSession(sessionToken, userId)
|
||||||
|
auth.setSessionTokenCookie(
|
||||||
|
event,
|
||||||
|
sessionToken,
|
||||||
|
new Date(session.expiresAt),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
return fail(500, { message: 'An error has occurred' })
|
||||||
|
}
|
||||||
|
return redirect(302, '/dashboard')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUserId() {
|
||||||
|
// ID with 120 bits of entropy, or about the same as UUID v4.
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(15))
|
||||||
|
const id = encodeBase32LowerCase(bytes)
|
||||||
|
return id
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms'
|
||||||
|
import type { ActionData } from './$types'
|
||||||
|
|
||||||
|
let { form }: { form: ActionData } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Login/Register</h1>
|
||||||
|
<form method="post" action="?/login" use:enhance>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input name="email" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" name="password" />
|
||||||
|
</label>
|
||||||
|
<button>Login</button>
|
||||||
|
<button formaction="?/register">Register</button>
|
||||||
|
</form>
|
||||||
|
<p style="color: red">{form?.message ?? ''}</p>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue