Added dashboard screens

This commit is contained in:
Karl Ludwig Weise
2025-09-01 16:00:32 +02:00
parent ee7ea1883a
commit 5900bf8bbb
107 changed files with 2717 additions and 30 deletions
+4 -4
View File
@@ -32,7 +32,7 @@
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.2.0",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.515.0",
"@lucide/svelte": "^0.542.0",
"@playwright/test": "^1.53.1",
"@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/kit": "^2.22.0",
@@ -1619,9 +1619,9 @@
"license": "Apache-2.0"
},
"node_modules/@lucide/svelte": {
"version": "0.515.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.515.0.tgz",
"integrity": "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA==",
"version": "0.542.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.542.0.tgz",
"integrity": "sha512-NuWttxTVfMSURpOxcKiKvoCtma3JtEpcJWzF/0cO69saZfXlv6G8NYAvEEGLmk75YPl+I+ROe+F97WhddM8r2A==",
"dev": true,
"license": "ISC",
"peerDependencies": {
+1 -1
View File
@@ -40,7 +40,7 @@
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.2.0",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.515.0",
"@lucide/svelte": "^0.542.0",
"@playwright/test": "^1.53.1",
"@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/kit": "^2.22.0",
+18
View File
@@ -92,5 +92,23 @@
"error": "Passkey konnte nicht hinzugefügt werden",
"retry": "Erneut versuchen"
}
},
"nav": {
"home": "Übersicht",
"tenants": "Mandanten",
"calendar": "Kalender",
"staff": "Personal",
"agents": "Agenten",
"channels": "Kanäle",
"absences": "Abwesenheiten",
"account": "Konto",
"settings": "Einstellungen",
"documentation": "Dokumentation",
"addTenant": "Mandant hinzufügen",
"noTenantSelected": {
"title": "Kein Mandant ausgewählt",
"description": "Klicke zum Auswählen"
},
"logout": "Ausloggen"
}
}
+18
View File
@@ -100,5 +100,23 @@
"error": "Passkey could not be added",
"retry": "Retry"
}
},
"nav": {
"home": "Home",
"tenants": "Tenants",
"calendar": "Calendar",
"staff": "Staff",
"agents": "Agents",
"channels": "Channels",
"absences": "Absences",
"account": "Account",
"settings": "Settings",
"documentation": "Documentation",
"addTenant": "Add tenant",
"noTenantSelected": {
"title": "No tenant selected",
"description": "Click to select"
},
"logout": "Log out"
}
}
@@ -0,0 +1,27 @@
<script lang="ts">
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import type { ComponentProps } from "svelte";
import NavPrimary from "./nav-primary.svelte";
import NavSecondary from "./nav-secondary.svelte";
import NavUser from "./nav-user.svelte";
import TenantSwitcher from "./tenant-switcher.svelte";
let {
ref = $bindable(null),
collapsible = "icon",
...restProps
}: ComponentProps<typeof Sidebar.Root> = $props();
</script>
<Sidebar.Root {collapsible} {...restProps}>
<Sidebar.Header>
<TenantSwitcher />
</Sidebar.Header>
<Sidebar.Content class="flex flex-col justify-between gap-3">
<NavPrimary />
<NavSecondary />
</Sidebar.Content>
<Sidebar.Footer>
<NavUser />
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
@@ -0,0 +1,70 @@
<script lang="ts">
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { isCurrentSection } from "$lib/utils/routes";
import TenantsIcon from "@lucide/svelte/icons/tickets";
import CalendarIcon from "@lucide/svelte/icons/calendar";
import HomeIcon from "@lucide/svelte/icons/house";
import ChannelsIcon from "@lucide/svelte/icons/split";
import AbsencesIcon from "@lucide/svelte/icons/tree-palm";
import AgentsIcon from "@lucide/svelte/icons/user-star";
import { m } from "$i18n/messages";
const items = [
{
title: m["nav.home"](),
url: ROUTES.DASHBOARD.MAIN,
isTenatOnly: false,
icon: HomeIcon
},
{
title: m["nav.tenants"](),
url: ROUTES.DASHBOARD.TENANTS,
isTenatOnly: false,
icon: TenantsIcon
},
{
title: m["nav.calendar"](),
url: ROUTES.DASHBOARD.CALENDAR,
isTenatOnly: true,
icon: CalendarIcon
},
{
title: m["nav.agents"](),
url: ROUTES.DASHBOARD.AGENTS,
isTenatOnly: true,
icon: AgentsIcon
},
{
title: m["nav.channels"](),
url: ROUTES.DASHBOARD.CHANNELS,
isTenatOnly: true,
icon: ChannelsIcon
},
{
title: m["nav.absences"](),
url: ROUTES.DASHBOARD.ABSENCES,
isTenatOnly: true,
icon: AbsencesIcon
}
];
</script>
<Sidebar.Group class="gap-1">
{#each items as item (item.title)}
{#if item.isTenatOnly === false || $auth.user?.tenantId}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
{#snippet child({ props })}
<a href={item.url} {...props}>
<item.icon />
<Text style="md" class="ml-2">{item.title}</Text>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Group>
@@ -0,0 +1,57 @@
<script lang="ts">
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import { isCurrentSection } from "$lib/utils/routes";
import DocsIcon from "@lucide/svelte/icons/book-open-text";
import ExternalLinkIcon from "@lucide/svelte/icons/external-link";
import SettingsIcon from "@lucide/svelte/icons/settings-2";
import StaffIcon from "@lucide/svelte/icons/users";
import { auth } from "$lib/stores/auth";
import { m } from "$i18n/messages";
const items = [
{
title: m["nav.staff"](),
url: ROUTES.DASHBOARD.STAFF,
isTenatOnly: true,
icon: StaffIcon
},
{
title: m["nav.settings"](),
url: ROUTES.DASHBOARD.SETTINGS,
isTenatOnly: true,
icon: SettingsIcon
},
{
title: m["nav.documentation"](),
url: "https://open-reception.org",
isTenatOnly: false,
icon: DocsIcon
}
];
</script>
<Sidebar.Group>
{#each items as item (item.title)}
{#if item.isTenatOnly === false || $auth.user?.tenantId}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
{#snippet child({ props })}
<a
href={item.url}
{...props}
target={item.url.startsWith("http") ? "_blank" : undefined}
>
<item.icon />
<Text style="xs">{item.title}</Text>
{#if item.url.startsWith("http")}
<ExternalLinkIcon class="text-light ml-auto !size-3" />
{/if}
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Group>
@@ -0,0 +1,77 @@
<script lang="ts">
import { goto } from "$app/navigation";
import * as Avatar from "$lib/components/ui/avatar";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import * as Sidebar from "$lib/components/ui/sidebar";
import { useSidebar } from "$lib/components/ui/sidebar";
import { ROUTES } from "$lib/const/routes";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import AccountIcon from "@lucide/svelte/icons/shield-user";
import { auth } from "$lib/stores/auth";
import { nameToAvatarFallback } from "$lib/utils/name";
import { LanguageSwitch } from "$lib/components/templates/language-switch";
import { m } from "$i18n/messages";
const sidebar = useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg">
{nameToAvatarFallback($auth.user?.name)}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{$auth.user?.name}</span>
<span class="truncate text-xs">{$auth.user?.email}</span>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg">
{nameToAvatarFallback($auth.user?.name)}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{$auth.user?.name}</span>
<span class="truncate text-xs">{$auth.user?.email}</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<LanguageSwitch class="w-full" triggerClass="w-[100%] [&>svg]:ml-auto" />
<DropdownMenu.Item onclick={() => goto(ROUTES.DASHBOARD.ACCOUNT)}>
<AccountIcon />
{m["nav.account"]()}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto(ROUTES.LOGOUT)}>
<LogOutIcon />
{m["nav.logout"]()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -0,0 +1,85 @@
<script lang="ts">
import { m } from "$i18n/messages";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import * as Sidebar from "$lib/components/ui/sidebar";
import { useSidebar } from "$lib/components/ui/sidebar";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import type { TTenant } from "$lib/types/tenant";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import UnknownTenantIcon from "@lucide/svelte/icons/ticket-x";
import PlusIcon from "@lucide/svelte/icons/plus";
const sidebar = useSidebar();
const tenants: TTenant[] = [
// {
// id: "praxis-1",
// name: "Praxis 1",
// logo: ChevronsUpDownIcon
// },
// {
// id: "praxis-2",
// name: "Praxis 2",
// logo: ChevronsUpDownIcon
// }
];
let activeTenantId = $auth.user?.tenantId;
let activeTenant = $derived(tenants.find((t) => t.id === activeTenantId));
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
<UnknownTenantIcon class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<Text style="md" class="truncate font-medium">
{activeTenant?.name ?? m["nav.noTenantSelected.title"]()}
</Text>
<Text style="xs" class="truncate">
{activeTenant?.url ?? m["nav.noTenantSelected.description"]()}
</Text>
</div>
<ChevronsUpDownIcon class="ml-auto" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
align="start"
side={sidebar.isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenu.Label class="text-muted-foreground text-xs"
>{m["nav.tenants"]()}</DropdownMenu.Label
>
{#each tenants as tenant, index (tenant.id)}
<DropdownMenu.Item onSelect={() => (activeTenantId = tenant.id)} class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border">
<UnknownTenantIcon class="size-3.5 shrink-0" />
</div>
{tenant.name}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border bg-transparent">
<PlusIcon class="size-4" />
</div>
<div class="text-muted-foreground font-medium">{m["nav.addTenant"]()}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -0,0 +1,3 @@
import SidebarLayout from "./root.svelte";
export { SidebarLayout };
@@ -0,0 +1,56 @@
<script lang="ts">
import AppSidebar from "$lib/components/layouts/sidebar-layout/components/app-sidebar.svelte";
import * as Breadcrumb from "$lib/components/ui/breadcrumb";
import { HorizontalPagePadding, PageWithClaim } from "$lib/components/ui/page";
import { Separator } from "$lib/components/ui/separator";
import * as Sidebar from "$lib/components/ui/sidebar";
import type { HTMLAttributes } from "svelte/elements";
import { sidebar } from "$lib/stores/sidebar";
let {
children,
breakcrumbs
}: HTMLAttributes<HTMLDivElement> & { breakcrumbs?: Array<{ label: string; href: string }> } =
$props();
</script>
<Sidebar.Provider bind:open={$sidebar.isOpen} onOpenChange={(open) => sidebar.setOpen(open)}>
<AppSidebar />
<Sidebar.Inset>
<PageWithClaim>
<HorizontalPagePadding>
<header
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
>
<div class="flex items-center gap-2">
<Sidebar.Trigger class="-ml-1" />
{#if breakcrumbs && breakcrumbs.length > 0}
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb.Root>
<Breadcrumb.List>
{#each breakcrumbs as crumb, index (`${crumb.href}-${index}`)}
{#if index === breakcrumbs.length - 1}
<Breadcrumb.Item>
<Breadcrumb.Page>
{crumb.label}
</Breadcrumb.Page>
</Breadcrumb.Item>
{:else}
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href={crumb.href}>
{crumb.label}
</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
{/if}
{/each}
</Breadcrumb.List>
</Breadcrumb.Root>
{/if}
</div>
</header>
{@render children?.()}
</HorizontalPagePadding>
</PageWithClaim>
</Sidebar.Inset>
</Sidebar.Provider>
@@ -3,12 +3,19 @@
import { getLocale, setLocale } from "$i18n/runtime.js";
import type { ButtonSize, ButtonVariant } from "$lib/components/ui/button";
import { ComboBox } from "$lib/components/ui/combobox";
import { cn } from "$lib/utils";
let {
class: className = "",
triggerClass = "",
triggerSize = "default",
triggerVariant = "ghost"
}: { triggerVariant?: ButtonVariant; triggerSize?: ButtonSize; class?: string } = $props();
}: {
triggerVariant?: ButtonVariant;
triggerSize?: ButtonSize;
class?: string;
triggerClass?: string;
} = $props();
</script>
<div class={className}>
@@ -28,6 +35,7 @@
}}
{triggerVariant}
{triggerSize}
class="w-auto"
{triggerClass}
class={cn("w-auto")}
/>
</div>
@@ -0,0 +1,17 @@
<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
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<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
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>
+13
View File
@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("flex size-9 items-center justify-center", className)}
{...restProps}
>
<EllipsisIcon class="size-4" />
<span class="sr-only">More</span>
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("inline-flex items-center gap-1.5", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLOlAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOlAttributes> = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5",
className
)}
{...restProps}
>
{@render children?.()}
</ol>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn("text-foreground font-normal", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,27 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLLiAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn("[&>svg]:size-3.5", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
class={className}
aria-label="breadcrumb"
{...restProps}
>
{@render children?.()}
</nav>
+25
View File
@@ -0,0 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};
+1 -1
View File
@@ -5,7 +5,7 @@
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 select-none",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
@@ -0,0 +1,11 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
};
+17 -12
View File
@@ -14,7 +14,8 @@
onChange,
class: className = "",
triggerVariant = "outline",
triggerSize = "default"
triggerSize = "default",
triggerClass = ""
}: {
labels: {
placeholder: string;
@@ -25,6 +26,7 @@
onChange: (value: string) => void;
triggerVariant?: ButtonVariant;
triggerSize?: ButtonSize;
triggerClass?: string;
class?: string;
options?: { label: string; value: string; keywords?: string[] }[];
} = $props();
@@ -46,17 +48,20 @@
</script>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}>
<Button
variant={triggerVariant}
size={triggerSize}
class={cn("w-[200px] justify-between", className)}
role="combobox"
aria-expanded={open}
>
{selectedValue || labels.placeholder}
<ChevronsUpDownIcon class="opacity-50" />
</Button>
<Popover.Trigger bind:ref={triggerRef} class={triggerClass}>
{#snippet child({ props })}
<Button
variant={triggerVariant}
size={triggerSize}
class={cn("w-[200px] justify-between", className)}
{...props}
role="combobox"
aria-expanded={open}
>
{selectedValue || labels.placeholder}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
@@ -0,0 +1,41 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
"bg-popover text-popover-foreground 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-[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 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn("bg-border -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,20 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
"bg-popover text-popover-foreground 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-[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 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,49 @@
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import Group from "./dropdown-menu-group.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioGroup from "./dropdown-menu-radio-group.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import Trigger from "./dropdown-menu-trigger.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
export {
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot="separator"
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>
+36
View File
@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from "bits-ui";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
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 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,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
@@ -0,0 +1,58 @@
<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 flex flex-col gap-4 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 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto 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 } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } 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
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
@@ -0,0 +1,17 @@
<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
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<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();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
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/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<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
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...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));
}
+75
View File
@@ -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 { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-content"
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 { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-footer"
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, type WithElementRef } from "$lib/utils.js";
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 mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 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 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-group-action",
"data-sidebar": "group-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group-content"
data-sidebar="group-content"
class={cn("w-full text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
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 outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] 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-slot": "sidebar-group-label",
"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 { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group"
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 { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-header"
data-sidebar="header"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<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-slot="sidebar-input"
data-sidebar="input"
class={cn("bg-background h-8 w-full shadow-none", className)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<main
bind:this={ref}
data-slot="sidebar-inset"
class={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</main>
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
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 outline-hidden absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 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 md:after: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-slot": "sidebar-menu-action",
"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, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-badge"
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,103 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 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 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>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_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "group-data-[collapsible=icon]:p-0! h-12 text-sm",
},
},
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, type WithElementRef, type WithoutChildrenOrChild } from "$lib/utils.js";
import { mergeProps } 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 | string;
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-slot": "sidebar-menu-button",
"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}
{...tooltipContentProps}
>
{#if typeof tooltipContent === "string"}
{tooltipContent}
{:else if tooltipContent}
{@render tooltipContent()}
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
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-slot="sidebar-menu-item"
data-sidebar="menu-item"
class={cn("group/menu-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
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-slot="sidebar-menu-skeleton"
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="max-w-(--skeleton-width) h-4 flex-1"
data-sidebar="menu-skeleton-text"
style="--skeleton-width: {width};"
/>
{@render children?.()}
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAnchorAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
size = "md",
isActive = false,
...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 outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 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-slot": "sidebar-menu-sub-button",
"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,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
class={cn("group/menu-sub-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu-sub"
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, type WithElementRef } from "$lib/utils.js";
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-slot="sidebar-menu"
data-sidebar="menu"
class={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...restProps}
>
{@render children?.()}
</ul>
@@ -0,0 +1,50 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
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);
}
});
</script>
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
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, type WithElementRef } from "$lib/utils.js";
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"
data-slot="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-[calc(1/2*100%-1px)] after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]: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,19 @@
<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-slot="sidebar-separator"
data-sidebar="separator"
class={cn("bg-sidebar-border", className)}
{...restProps}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import PanelLeftIcon 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
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
class={cn("size-7", className)}
type="button"
onclick={(e) => {
onclick?.(e);
sidebar.toggle();
}}
{...restProps}
>
<PanelLeftIcon />
<span class="sr-only">Toggle Sidebar</span>
</Button>
@@ -0,0 +1,104 @@
<script lang="ts">
import * as Sheet from "$lib/components/ui/sheet/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
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 w-(--sidebar-width) flex h-full 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-slot="sidebar"
data-mobile="true"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
<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}
data-slot="sidebar"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
data-slot="sidebar-gap"
class={cn(
"w-(--sidebar-width) relative 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)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
></div>
<div
data-slot="sidebar-container"
class={cn(
"w-(--sidebar-width) fixed inset-y-0 z-10 hidden h-svh 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)+(--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"
data-slot="sidebar-inner"
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-sm"
>
{@render children?.()}
</div>
</div>
</div>
{/if}
+21
View File
@@ -0,0 +1,21 @@
import { Tooltip as TooltipPrimitive } from "bits-ui";
import Trigger from "./tooltip-trigger.svelte";
import Content from "./tooltip-content.svelte";
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
const Portal = TooltipPrimitive.Portal;
export {
Root,
Trigger,
Content,
Provider,
Portal,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
};
@@ -0,0 +1,47 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
children,
arrowClasses,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
} = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
"bg-primary text-primary-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 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
+9 -1
View File
@@ -8,6 +8,14 @@ export const ROUTES = {
LOGIN: "/login",
LOGOUT: "/logout",
DASHBOARD: {
MAIN: "/dashboard"
MAIN: "/dashboard",
TENANTS: "/dashboard/tenants",
CALENDAR: "/dashboard/calendar",
STAFF: "/dashboard/staff",
AGENTS: "/dashboard/agents",
CHANNELS: "/dashboard/channels",
ABSENCES: "/dashboard/absences",
SETTINGS: "/dashboard/settings",
ACCOUNT: "/dashboard/account"
}
};
+9
View File
@@ -0,0 +1,9 @@
import { MediaQuery } from "svelte/reactivity";
const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile extends MediaQuery {
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
super(`max-width: ${breakpoint - 1}px`);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { writable } from "svelte/store";
interface AuthState {
isAuthenticated: boolean;
isRefreshing: boolean;
user?: {
id: string;
email: string;
name: string;
role: string;
// The currently selected tenant
tenantId?: string | null;
};
}
function createAuthStore() {
const store = writable<AuthState>({
isAuthenticated: false,
isRefreshing: false
});
return {
...store,
setRefreshing: (isRefreshing: boolean) => {
store.update((state) => ({ ...state, isRefreshing }));
},
setAuthenticated: (isAuthenticated: boolean) => {
store.update((state) => ({ ...state, isAuthenticated }));
},
setUser: (user: AuthState["user"]) => {
store.update((state) => ({ ...state, isAuthenticated: true, user }));
},
reset: () => {
store.set({
isAuthenticated: false,
isRefreshing: false,
user: undefined
});
}
};
}
export const auth = createAuthStore();
+25
View File
@@ -0,0 +1,25 @@
import { browser } from "$app/environment";
import { writable } from "svelte/store";
interface AuthState {
isOpen: boolean;
}
const LOCAL_STORAGE_KEY = "sidebar-is-open";
function createSidebarStore() {
const storedValue = browser ? localStorage.getItem(LOCAL_STORAGE_KEY) : null;
const store = writable<AuthState>({
isOpen: storedValue === "true" ? true : false
});
return {
...store,
setOpen: (isOpen: boolean) => {
localStorage.setItem(LOCAL_STORAGE_KEY, `${isOpen}`);
store.update((state) => ({ ...state, isOpen }));
}
};
}
export const sidebar = createSidebarStore();
+6
View File
@@ -0,0 +1,6 @@
export type TTenant = {
id: string;
name: string;
url: string;
logo?: string;
};
+9
View File
@@ -0,0 +1,9 @@
import type { UserRole } from "$lib/server/auth/authorization-service";
export type TUser = {
id: string;
email: string;
name: string;
role: UserRole;
tenantId: string | null;
};
+8
View File
@@ -0,0 +1,8 @@
export const nameToAvatarFallback = (name?: string) => {
if (!name) return "NN";
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
+16
View File
@@ -0,0 +1,16 @@
import { page } from "$app/state";
import { ROUTES } from "$lib/const/routes";
export const isCurrentSection = (sectionPath: string): boolean => {
// Select home when on dashboard main
if (page.url.pathname === ROUTES.DASHBOARD.MAIN && sectionPath === ROUTES.DASHBOARD.MAIN) {
return true;
}
// Not always select home
if (page.url.pathname.startsWith(sectionPath) && sectionPath === ROUTES.DASHBOARD.MAIN) {
return false;
}
return page.url.pathname.startsWith(sectionPath);
};
+48
View File
@@ -0,0 +1,48 @@
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
export const refreshSession = async () => {
auth.setRefreshing(true);
try {
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
credentials: "same-origin"
});
if (!response.ok) {
// Token is invalid, clear and redirect to login
if (response.status === 401) {
auth.setRefreshing(false);
goto(ROUTES.LOGOUT);
return;
}
}
refreshUserData();
} finally {
auth.setRefreshing(false);
}
};
export const refreshUserData = async () => {
try {
const response = await fetch("/api/auth/session", {
method: "GET",
headers: {
"Content-Type": "application/json"
},
credentials: "same-origin"
});
const data = await response.json();
console.log("refreshed user data", data);
// auth.setUser(data.user);
} catch (error) {
console.error("Failed to refresh user data", error);
}
};
@@ -0,0 +1,32 @@
<script lang="ts">
import { refreshSession } from "$lib/utils/session";
import { onMount } from "svelte";
let { children } = $props();
let intervalId: ReturnType<typeof setInterval> | null = null;
onMount(() => {
refreshSession();
const unsubscribe = () => {
if (!intervalId) {
refreshSession();
intervalId = setInterval(refreshSession, 10 * 1000); // 10 minutes
}
};
const handleFocus = () => {
if (document.visibilityState === "visible") {
refreshSession();
}
};
document.addEventListener("visibilitychange", handleFocus);
return () => {
unsubscribe();
document.removeEventListener("visibilitychange", handleFocus);
};
});
</script>
{@render children()}
+14 -2
View File
@@ -1,2 +1,14 @@
Hello from Dashboard<br />
<a href="/logout" class="underline">Logout</a>
<script lang="ts">
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { ROUTES } from "$lib/const/routes";
</script>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.home"](),
href: ROUTES.DASHBOARD.MAIN
}
]}
></SidebarLayout>
@@ -0,0 +1,14 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { ROUTES } from "$lib/const/routes";
</script>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.absences"](),
href: ROUTES.DASHBOARD.ABSENCES
}
]}
></SidebarLayout>
@@ -0,0 +1,14 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { ROUTES } from "$lib/const/routes";
</script>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.account"](),
href: ROUTES.DASHBOARD.ACCOUNT
}
]}
></SidebarLayout>
@@ -0,0 +1,14 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { ROUTES } from "$lib/const/routes";
</script>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.agents"](),
href: ROUTES.DASHBOARD.AGENTS
}
]}
></SidebarLayout>
@@ -0,0 +1,8 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { ROUTES } from "$lib/const/routes";
</script>
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}
></SidebarLayout>
@@ -0,0 +1,14 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { ROUTES } from "$lib/const/routes";
</script>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.channels"](),
href: ROUTES.DASHBOARD.CHANNELS
}
]}
></SidebarLayout>

Some files were not shown because too many files have changed in this diff Show More