diff --git a/package-lock.json b/package-lock.json
index 4da4c5a..6fbf512 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/package.json b/package.json
index 3fb41c6..de8ae7a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json
index e155f42..fda6c22 100644
--- a/project.inlang/messages/de.json
+++ b/project.inlang/messages/de.json
@@ -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"
}
}
diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json
index 43b650e..e703dbd 100644
--- a/project.inlang/messages/en.json
+++ b/project.inlang/messages/en.json
@@ -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"
}
}
diff --git a/src/lib/components/layouts/sidebar-layout/components/app-sidebar.svelte b/src/lib/components/layouts/sidebar-layout/components/app-sidebar.svelte
new file mode 100644
index 0000000..1fc0379
--- /dev/null
+++ b/src/lib/components/layouts/sidebar-layout/components/app-sidebar.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte
new file mode 100644
index 0000000..72def17
--- /dev/null
+++ b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte
@@ -0,0 +1,70 @@
+
+
+
+ {#each items as item (item.title)}
+ {#if item.isTenatOnly === false || $auth.user?.tenantId}
+
+
+ {#snippet child({ props })}
+
+
+ {item.title}
+
+ {/snippet}
+
+
+ {/if}
+ {/each}
+
diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte
new file mode 100644
index 0000000..ddfd2dc
--- /dev/null
+++ b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte
@@ -0,0 +1,57 @@
+
+
+
+ {#each items as item (item.title)}
+ {#if item.isTenatOnly === false || $auth.user?.tenantId}
+
+
+ {#snippet child({ props })}
+
+
+ {item.title}
+ {#if item.url.startsWith("http")}
+
+ {/if}
+
+ {/snippet}
+
+
+ {/if}
+ {/each}
+
diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte
new file mode 100644
index 0000000..b5c075c
--- /dev/null
+++ b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+
+
diff --git a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte
new file mode 100644
index 0000000..0e72784
--- /dev/null
+++ b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+
+
diff --git a/src/lib/components/layouts/sidebar-layout/index.ts b/src/lib/components/layouts/sidebar-layout/index.ts
new file mode 100644
index 0000000..d34dd9a
--- /dev/null
+++ b/src/lib/components/layouts/sidebar-layout/index.ts
@@ -0,0 +1,3 @@
+import SidebarLayout from "./root.svelte";
+
+export { SidebarLayout };
diff --git a/src/lib/components/layouts/sidebar-layout/root.svelte b/src/lib/components/layouts/sidebar-layout/root.svelte
new file mode 100644
index 0000000..e26df38
--- /dev/null
+++ b/src/lib/components/layouts/sidebar-layout/root.svelte
@@ -0,0 +1,56 @@
+
+
+ sidebar.setOpen(open)}>
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/src/lib/components/templates/language-switch/language-switch.svelte b/src/lib/components/templates/language-switch/language-switch.svelte
index 890ed83..7c7611e 100644
--- a/src/lib/components/templates/language-switch/language-switch.svelte
+++ b/src/lib/components/templates/language-switch/language-switch.svelte
@@ -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();
@@ -28,6 +35,7 @@
}}
{triggerVariant}
{triggerSize}
- class="w-auto"
+ {triggerClass}
+ class={cn("w-auto")}
/>
diff --git a/src/lib/components/ui/avatar/avatar-fallback.svelte b/src/lib/components/ui/avatar/avatar-fallback.svelte
new file mode 100644
index 0000000..249d4a4
--- /dev/null
+++ b/src/lib/components/ui/avatar/avatar-fallback.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/avatar/avatar-image.svelte b/src/lib/components/ui/avatar/avatar-image.svelte
new file mode 100644
index 0000000..2bb9db4
--- /dev/null
+++ b/src/lib/components/ui/avatar/avatar-image.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/avatar/avatar.svelte b/src/lib/components/ui/avatar/avatar.svelte
new file mode 100644
index 0000000..e37214d
--- /dev/null
+++ b/src/lib/components/ui/avatar/avatar.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/src/lib/components/ui/avatar/index.ts b/src/lib/components/ui/avatar/index.ts
new file mode 100644
index 0000000..d06457b
--- /dev/null
+++ b/src/lib/components/ui/avatar/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
new file mode 100644
index 0000000..a178cf5
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+ More
+
diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte
new file mode 100644
index 0000000..1a84c4c
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte
new file mode 100644
index 0000000..e6bc17d
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte
@@ -0,0 +1,31 @@
+
+
+{#if child}
+ {@render child({ props: attrs })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte
new file mode 100644
index 0000000..b5458fa
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte
new file mode 100644
index 0000000..5fb6979
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte
new file mode 100644
index 0000000..84106a1
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte
@@ -0,0 +1,27 @@
+
+
+svg]:size-3.5", className)}
+ {...restProps}
+>
+ {#if children}
+ {@render children?.()}
+ {:else}
+
+ {/if}
+
diff --git a/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/src/lib/components/ui/breadcrumb/breadcrumb.svelte
new file mode 100644
index 0000000..8f8a3e6
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/breadcrumb.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/breadcrumb/index.ts b/src/lib/components/ui/breadcrumb/index.ts
new file mode 100644
index 0000000..dc914ec
--- /dev/null
+++ b/src/lib/components/ui/breadcrumb/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte
index 46d9ec8..d5bf4bc 100644
--- a/src/lib/components/ui/button/button.svelte
+++ b/src/lib/components/ui/button/button.svelte
@@ -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",
diff --git a/src/lib/components/ui/collapsible/collapsible-content.svelte b/src/lib/components/ui/collapsible/collapsible-content.svelte
new file mode 100644
index 0000000..bdabb55
--- /dev/null
+++ b/src/lib/components/ui/collapsible/collapsible-content.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/src/lib/components/ui/collapsible/collapsible-trigger.svelte
new file mode 100644
index 0000000..ece7ad6
--- /dev/null
+++ b/src/lib/components/ui/collapsible/collapsible-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/collapsible/collapsible.svelte b/src/lib/components/ui/collapsible/collapsible.svelte
new file mode 100644
index 0000000..39cdd4e
--- /dev/null
+++ b/src/lib/components/ui/collapsible/collapsible.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/src/lib/components/ui/collapsible/index.ts b/src/lib/components/ui/collapsible/index.ts
new file mode 100644
index 0000000..169b479
--- /dev/null
+++ b/src/lib/components/ui/collapsible/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/combobox/combobox.svelte b/src/lib/components/ui/combobox/combobox.svelte
index 7286e41..9353567 100644
--- a/src/lib/components/ui/combobox/combobox.svelte
+++ b/src/lib/components/ui/combobox/combobox.svelte
@@ -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 @@
-
-
+
+ {#snippet child({ props })}
+
+ {/snippet}
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
new file mode 100644
index 0000000..e03f949
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#snippet children({ checked, indeterminate })}
+
+ {#if indeterminate}
+
+ {:else}
+
+ {/if}
+
+ {@render childrenProp?.()}
+ {/snippet}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
new file mode 100644
index 0000000..907ef73
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
new file mode 100644
index 0000000..48d14a9
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte
new file mode 100644
index 0000000..aca1f7b
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
new file mode 100644
index 0000000..64bb283
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
new file mode 100644
index 0000000..f72e477
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
new file mode 100644
index 0000000..189aef4
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
new file mode 100644
index 0000000..513170a
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {#snippet children({ checked })}
+
+ {#if checked}
+
+ {/if}
+
+ {@render childrenProp?.({ checked })}
+ {/snippet}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
new file mode 100644
index 0000000..90f1b6f
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
new file mode 100644
index 0000000..6974947
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
new file mode 100644
index 0000000..10e14ca
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
new file mode 100644
index 0000000..f9b286a
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte
new file mode 100644
index 0000000..cb05344
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts
new file mode 100644
index 0000000..1cf9f70
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts
new file mode 100644
index 0000000..82442d2
--- /dev/null
+++ b/src/lib/components/ui/separator/index.ts
@@ -0,0 +1,7 @@
+import Root from "./separator.svelte";
+
+export {
+ Root,
+ //
+ Root as Separator,
+};
diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte
new file mode 100644
index 0000000..09d88f4
--- /dev/null
+++ b/src/lib/components/ui/separator/separator.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/index.ts b/src/lib/components/ui/sheet/index.ts
new file mode 100644
index 0000000..01d40c8
--- /dev/null
+++ b/src/lib/components/ui/sheet/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/sheet/sheet-close.svelte b/src/lib/components/ui/sheet/sheet-close.svelte
new file mode 100644
index 0000000..ae382c1
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte
new file mode 100644
index 0000000..856922e
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-content.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+ Close
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-description.svelte b/src/lib/components/ui/sheet/sheet-description.svelte
new file mode 100644
index 0000000..333b17a
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-footer.svelte b/src/lib/components/ui/sheet/sheet-footer.svelte
new file mode 100644
index 0000000..dd9ed84
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sheet/sheet-header.svelte b/src/lib/components/ui/sheet/sheet-header.svelte
new file mode 100644
index 0000000..757a6a5
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sheet/sheet-overlay.svelte b/src/lib/components/ui/sheet/sheet-overlay.svelte
new file mode 100644
index 0000000..345e197
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-title.svelte b/src/lib/components/ui/sheet/sheet-title.svelte
new file mode 100644
index 0000000..9fda327
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/sheet/sheet-trigger.svelte b/src/lib/components/ui/sheet/sheet-trigger.svelte
new file mode 100644
index 0000000..e266975
--- /dev/null
+++ b/src/lib/components/ui/sheet/sheet-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/constants.ts b/src/lib/components/ui/sidebar/constants.ts
new file mode 100644
index 0000000..4de4435
--- /dev/null
+++ b/src/lib/components/ui/sidebar/constants.ts
@@ -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";
diff --git a/src/lib/components/ui/sidebar/context.svelte.ts b/src/lib/components/ui/sidebar/context.svelte.ts
new file mode 100644
index 0000000..15248ad
--- /dev/null
+++ b/src/lib/components/ui/sidebar/context.svelte.ts
@@ -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;
+
+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;
+
+ /**
+ * 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 ``
+ 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));
+}
diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts
new file mode 100644
index 0000000..318a341
--- /dev/null
+++ b/src/lib/components/ui/sidebar/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/sidebar/sidebar-content.svelte b/src/lib/components/ui/sidebar/sidebar-content.svelte
new file mode 100644
index 0000000..f121800
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-content.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-footer.svelte b/src/lib/components/ui/sidebar/sidebar-footer.svelte
new file mode 100644
index 0000000..6259cb9
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-footer.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/src/lib/components/ui/sidebar/sidebar-group-action.svelte
new file mode 100644
index 0000000..fb84e4a
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group-action.svelte
@@ -0,0 +1,36 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/src/lib/components/ui/sidebar/sidebar-group-content.svelte
new file mode 100644
index 0000000..415255f
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group-content.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/src/lib/components/ui/sidebar/sidebar-group-label.svelte
new file mode 100644
index 0000000..e292945
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group-label.svelte
@@ -0,0 +1,34 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-group.svelte b/src/lib/components/ui/sidebar/sidebar-group.svelte
new file mode 100644
index 0000000..ec18a69
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-group.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-header.svelte b/src/lib/components/ui/sidebar/sidebar-header.svelte
new file mode 100644
index 0000000..a1b2db1
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-header.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-input.svelte b/src/lib/components/ui/sidebar/sidebar-input.svelte
new file mode 100644
index 0000000..19b3666
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-input.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-inset.svelte b/src/lib/components/ui/sidebar/sidebar-inset.svelte
new file mode 100644
index 0000000..d862761
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-inset.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
new file mode 100644
index 0000000..fa3fb0c
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
new file mode 100644
index 0000000..69e5a3c
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
new file mode 100644
index 0000000..4bef683
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+{#snippet Button({ props }: { props?: Record })}
+ {@const mergedProps = mergeProps(buttonProps, props)}
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {/if}
+{/snippet}
+
+{#if !tooltipContent}
+ {@render Button({})}
+{:else}
+
+
+ {#snippet child({ props })}
+ {@render Button({ props })}
+ {/snippet}
+
+
+ {#if typeof tooltipContent === "string"}
+ {tooltipContent}
+ {:else if tooltipContent}
+ {@render tooltipContent()}
+ {/if}
+
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
new file mode 100644
index 0000000..4db4453
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
new file mode 100644
index 0000000..cc63b04
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
@@ -0,0 +1,36 @@
+
+
+
+ {#if showIcon}
+
+ {/if}
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
new file mode 100644
index 0000000..987f104
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
new file mode 100644
index 0000000..681d0f1
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
new file mode 100644
index 0000000..8ab1111
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-menu.svelte b/src/lib/components/ui/sidebar/sidebar-menu.svelte
new file mode 100644
index 0000000..946ccce
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-menu.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/sidebar/sidebar-provider.svelte b/src/lib/components/ui/sidebar/sidebar-provider.svelte
new file mode 100644
index 0000000..5665abe
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-provider.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-rail.svelte b/src/lib/components/ui/sidebar/sidebar-rail.svelte
new file mode 100644
index 0000000..c180cf5
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-rail.svelte
@@ -0,0 +1,36 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-separator.svelte b/src/lib/components/ui/sidebar/sidebar-separator.svelte
new file mode 100644
index 0000000..5a7deda
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-separator.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/src/lib/components/ui/sidebar/sidebar-trigger.svelte
new file mode 100644
index 0000000..1825182
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar-trigger.svelte
@@ -0,0 +1,35 @@
+
+
+
diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte
new file mode 100644
index 0000000..3e9eba9
--- /dev/null
+++ b/src/lib/components/ui/sidebar/sidebar.svelte
@@ -0,0 +1,104 @@
+
+
+{#if collapsible === "none"}
+
+ {@render children?.()}
+
+{:else if sidebar.isMobile}
+ sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
+ {...restProps}
+ >
+
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts
new file mode 100644
index 0000000..313a7f0
--- /dev/null
+++ b/src/lib/components/ui/tooltip/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte
new file mode 100644
index 0000000..e495efe
--- /dev/null
+++ b/src/lib/components/ui/tooltip/tooltip-content.svelte
@@ -0,0 +1,47 @@
+
+
+
+
+ {@render children?.()}
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte
new file mode 100644
index 0000000..1acdaa4
--- /dev/null
+++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts
index 2bbbffe..f82bf6f 100644
--- a/src/lib/const/routes.ts
+++ b/src/lib/const/routes.ts
@@ -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"
}
};
diff --git a/src/lib/hooks/is-mobile.svelte.ts b/src/lib/hooks/is-mobile.svelte.ts
new file mode 100644
index 0000000..4829c00
--- /dev/null
+++ b/src/lib/hooks/is-mobile.svelte.ts
@@ -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`);
+ }
+}
diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts
new file mode 100644
index 0000000..907113b
--- /dev/null
+++ b/src/lib/stores/auth.ts
@@ -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({
+ 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();
diff --git a/src/lib/stores/sidebar.ts b/src/lib/stores/sidebar.ts
new file mode 100644
index 0000000..3cba045
--- /dev/null
+++ b/src/lib/stores/sidebar.ts
@@ -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({
+ 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();
diff --git a/src/lib/types/tenant.ts b/src/lib/types/tenant.ts
new file mode 100644
index 0000000..0b2419c
--- /dev/null
+++ b/src/lib/types/tenant.ts
@@ -0,0 +1,6 @@
+export type TTenant = {
+ id: string;
+ name: string;
+ url: string;
+ logo?: string;
+};
diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts
new file mode 100644
index 0000000..2ee0868
--- /dev/null
+++ b/src/lib/types/user.ts
@@ -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;
+};
diff --git a/src/lib/utils/name.ts b/src/lib/utils/name.ts
new file mode 100644
index 0000000..0801ddc
--- /dev/null
+++ b/src/lib/utils/name.ts
@@ -0,0 +1,8 @@
+export const nameToAvatarFallback = (name?: string) => {
+ if (!name) return "NN";
+ return name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase();
+};
diff --git a/src/lib/utils/routes.ts b/src/lib/utils/routes.ts
new file mode 100644
index 0000000..ca4a9bc
--- /dev/null
+++ b/src/lib/utils/routes.ts
@@ -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);
+};
diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts
new file mode 100644
index 0000000..8bd2d9d
--- /dev/null
+++ b/src/lib/utils/session.ts
@@ -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);
+ }
+};
diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte
new file mode 100644
index 0000000..0bfe2c8
--- /dev/null
+++ b/src/routes/(pages)/dashboard/+layout.svelte
@@ -0,0 +1,32 @@
+
+
+{@render children()}
diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte
index f29a407..e73314b 100644
--- a/src/routes/(pages)/dashboard/+page.svelte
+++ b/src/routes/(pages)/dashboard/+page.svelte
@@ -1,2 +1,14 @@
-Hello from Dashboard
-Logout
+
+
+
diff --git a/src/routes/(pages)/dashboard/absences/+page.svelte b/src/routes/(pages)/dashboard/absences/+page.svelte
new file mode 100644
index 0000000..cffe1af
--- /dev/null
+++ b/src/routes/(pages)/dashboard/absences/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(pages)/dashboard/account/+page.svelte b/src/routes/(pages)/dashboard/account/+page.svelte
new file mode 100644
index 0000000..bb69be5
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte
new file mode 100644
index 0000000..001ed86
--- /dev/null
+++ b/src/routes/(pages)/dashboard/agents/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte
new file mode 100644
index 0000000..144e228
--- /dev/null
+++ b/src/routes/(pages)/dashboard/calendar/+page.svelte
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/routes/(pages)/dashboard/channels/+page.svelte b/src/routes/(pages)/dashboard/channels/+page.svelte
new file mode 100644
index 0000000..ed8fa2a
--- /dev/null
+++ b/src/routes/(pages)/dashboard/channels/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(pages)/dashboard/settings/+page.svelte b/src/routes/(pages)/dashboard/settings/+page.svelte
new file mode 100644
index 0000000..f705c4a
--- /dev/null
+++ b/src/routes/(pages)/dashboard/settings/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(pages)/dashboard/staff/+page.svelte b/src/routes/(pages)/dashboard/staff/+page.svelte
new file mode 100644
index 0000000..4de5067
--- /dev/null
+++ b/src/routes/(pages)/dashboard/staff/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(pages)/dashboard/tenants/+page.svelte b/src/routes/(pages)/dashboard/tenants/+page.svelte
new file mode 100644
index 0000000..8fa19eb
--- /dev/null
+++ b/src/routes/(pages)/dashboard/tenants/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts
index b2699d0..c6926ce 100644
--- a/src/routes/(pages)/login/+page.server.ts
+++ b/src/routes/(pages)/login/+page.server.ts
@@ -4,6 +4,10 @@ import { zod } from "sveltekit-superforms/adapters";
import type { Actions, PageServerLoad } from "./$types";
import { formSchema } from "./schema";
import type { WebAuthnCredential } from "$lib/server/auth/webauthn-service";
+import type { TUser } from "$lib/types/user";
+import logger from "$lib/logger";
+
+const log = logger.setContext("/login");
export const load: PageServerLoad = async () => {
return {
@@ -16,11 +20,10 @@ export const actions: Actions = {
const form = await superValidate(event, zod(formSchema));
if (!form.valid) {
- if (!form.valid) {
- return fail(400, {
- form: { ...form, data: { ...form.data, type: "passkey" } }
- });
- }
+ log.error("Login form is not valid", { errors: form.errors });
+ return fail(400, {
+ form: { ...form, data: { ...form.data, type: "passkey" } }
+ });
}
let body: { credential?: WebAuthnCredential; email: string; passphrase?: string } = {
@@ -52,8 +55,22 @@ export const actions: Actions = {
body: JSON.stringify(body)
});
- if (resp.status < 400) {
- return { form };
+ let user: TUser | null = null;
+ try {
+ const respJson = await resp.json();
+ user = {
+ id: respJson.user.id,
+ email: respJson.user.email,
+ name: respJson.user.name,
+ role: respJson.user.role,
+ tenantId: respJson.user.tenantId
+ };
+ } catch (error) {
+ log.error("Error parsing login response JSON", { error });
+ }
+
+ if (resp.status < 400 && user) {
+ return { form, user };
} else {
return fail(400, {
form
diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte
index d3a5183..5da576c 100644
--- a/src/routes/(pages)/login/login-form.svelte
+++ b/src/routes/(pages)/login/login-form.svelte
@@ -17,6 +17,7 @@
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { Label } from "$lib/components/ui/label";
+ import { auth } from "$lib/stores/auth";
let {
data,
@@ -34,6 +35,7 @@
},
onResult: async (event) => {
if (event.result.type === "success") {
+ auth.setUser(event.result.data?.user);
await goto(ROUTES.DASHBOARD.MAIN);
} else {
toast.error(m["login.error"]());
diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts
index 9a447df..81414c0 100644
--- a/src/routes/(pages)/logout/+page.server.ts
+++ b/src/routes/(pages)/logout/+page.server.ts
@@ -1,3 +1,4 @@
+import { auth } from "$lib/stores/auth";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
@@ -13,5 +14,7 @@ export const load: PageServerLoad = async (event) => {
return resp.status < 400;
});
+ auth.reset();
+
return { streaming: { success } };
};
diff --git a/src/server-hooks/authGuard.ts b/src/server-hooks/authGuard.ts
index 37bf404..5489e89 100644
--- a/src/server-hooks/authGuard.ts
+++ b/src/server-hooks/authGuard.ts
@@ -3,6 +3,7 @@ import { SessionService } from "$lib/server/auth/session-service";
import { AuthorizationService } from "$lib/server/auth/authorization-service";
import { getAccessToken } from "./utils/accessToken";
import { ROUTES } from "$lib/const/routes";
+import { auth } from "$lib/stores/auth";
export const authGuard: Handle = async ({ event, resolve }) => {
const { url } = event;
@@ -24,6 +25,15 @@ export const authGuard: Handle = async ({ event, resolve }) => {
const sessionData = await SessionService.validateTokenWithDB(accessToken);
if (!sessionData) redirect(302, ROUTES.LOGIN);
+ // Set user in auth store for SSR
+ auth.setUser({
+ id: sessionData.user.id,
+ email: sessionData.user.email,
+ name: sessionData.user.name,
+ role: sessionData.user.role,
+ tenantId: sessionData.user.tenantId
+ });
+
switch (true) {
case isDashboardRoute &&
AuthorizationService.hasAnyRole(sessionData.user, [