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
|
||||
// for information about these interfaces
|
||||
|
||||
import { user } from '$lib/db/schema'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
user: Omit<InferSelectModel<typeof user>, 'password'>
|
||||
session: {
|
||||
id: string
|
||||
userId: string
|
||||
expiresAt: number
|
||||
} | null
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// 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