feat: migrate auth from supabase to better auth (#596)
* feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
|
||||
</script>
|
||||
|
||||
<Badge variant="outline" class="ui:text-white bg-blue-500 dark:bg-blue-600 dark:text-black">
|
||||
{label}
|
||||
</Badge>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ComingSoon } from './coming-soon.svelte';
|
||||
export { default as PageLoadProgress } from './page-load-progress.svelte';
|
||||
export { default as AppHeader } from './navigation/app-header.svelte';
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
|
||||
import * as Drawer from '@cio/ui/base/drawer';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { buttonVariants } from '@cio/ui/base/button';
|
||||
import { page } from '$app/state';
|
||||
import { currentOrgPath, currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getOrgNavigationItems } from './org-navigation';
|
||||
import { generateBreadcrumbs } from './breadcrumb';
|
||||
import OrgSwitcher from '../sidebar/org-sidebar/org-switcher.svelte';
|
||||
|
||||
const navItems = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
|
||||
|
||||
const breadcrumbs = $derived(
|
||||
generateBreadcrumbs(
|
||||
page.url.pathname,
|
||||
page.url.search,
|
||||
navItems,
|
||||
$currentOrgPath,
|
||||
page.data as { breadcrumb?: string } | undefined
|
||||
)
|
||||
);
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
const isDesktop = new MediaQuery('(min-width: 768px)');
|
||||
const ITEMS_TO_DISPLAY = $derived(isDesktop.current ? 2 : 1); // Show last 2 items (since we have org switcher as first)
|
||||
</script>
|
||||
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.Item>
|
||||
<OrgSwitcher variant="breadcrumb" />
|
||||
</Breadcrumb.Item>
|
||||
|
||||
{#if breadcrumbs.length > 0}
|
||||
<Breadcrumb.Separator />
|
||||
|
||||
{#if breadcrumbs.length > ITEMS_TO_DISPLAY}
|
||||
<!-- Show ellipsis for hidden items -->
|
||||
<Breadcrumb.Item>
|
||||
{#if isDesktop.current}
|
||||
<DropdownMenu.Root bind:open>
|
||||
<DropdownMenu.Trigger class="flex items-center gap-1" aria-label="Toggle menu">
|
||||
<Breadcrumb.Ellipsis class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="start">
|
||||
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
|
||||
<DropdownMenu.Item>
|
||||
<a href={breadcrumb.href ? breadcrumb.href : '##'}>
|
||||
{breadcrumb.label}
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<Drawer.Root bind:open>
|
||||
<Drawer.Trigger aria-label="Toggle Menu">
|
||||
<Breadcrumb.Ellipsis class="size-4" />
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header class="text-start">
|
||||
<Drawer.Title>Navigate to</Drawer.Title>
|
||||
<Drawer.Description>Select a page to navigate to.</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
<div class="grid gap-1 px-4">
|
||||
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
|
||||
<a href={breadcrumb.href ? breadcrumb.href : '##'} class="py-1 text-sm">
|
||||
{breadcrumb.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<Drawer.Footer class="pt-4">
|
||||
<Drawer.Close class={buttonVariants({ variant: 'outline' })}>Close</Drawer.Close>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
{/if}
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator />
|
||||
{/if}
|
||||
|
||||
<!-- Show last ITEMS_TO_DISPLAY items (or all if <= ITEMS_TO_DISPLAY) -->
|
||||
{#each breadcrumbs.slice(-ITEMS_TO_DISPLAY) as breadcrumb, index (breadcrumb.label)}
|
||||
<Breadcrumb.Item>
|
||||
{@const isLast = index === breadcrumbs.slice(-ITEMS_TO_DISPLAY).length - 1}
|
||||
{#if !isLast}
|
||||
<Breadcrumb.Link href={breadcrumb.href} class="max-w-20 truncate md:max-w-none">
|
||||
{breadcrumb.label}
|
||||
</Breadcrumb.Link>
|
||||
<Breadcrumb.Separator />
|
||||
{:else}
|
||||
<Breadcrumb.Page class="max-w-20 truncate md:max-w-none">
|
||||
{breadcrumb.label}
|
||||
</Breadcrumb.Page>
|
||||
{/if}
|
||||
</Breadcrumb.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import Search from '../search.svelte';
|
||||
import AppBreadcrumbs from './app-breadcrumbs.svelte';
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
|
||||
<div class="h-4 w-2">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
|
||||
<AppBreadcrumbs />
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
<Search />
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" size="icon">
|
||||
<BellIcon class="custom rounded-full" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm">Your notifications will show here</p>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { NavItem } from './org-navigation';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate breadcrumbs from the current pathname using navigation structure
|
||||
*/
|
||||
export function generateBreadcrumbs(
|
||||
pathname: string,
|
||||
searchParams: string,
|
||||
navItems: NavItem[],
|
||||
currentOrgPath: string,
|
||||
pageData?: { breadcrumb?: string } // Optional page data for dynamic titles
|
||||
): BreadcrumbItem[] {
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
const pathWithQuery = pathname + searchParams;
|
||||
|
||||
// Remove the org path prefix to get the relative path
|
||||
const relativePath = pathname.replace(currentOrgPath, '').replace(/^\//, '') || '';
|
||||
const pathSegments = relativePath.split('/').filter(Boolean);
|
||||
|
||||
// Handle root/dashboard
|
||||
if (!relativePath || relativePath === '') {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Find the matching top-level nav item
|
||||
const firstSegment = pathSegments[0];
|
||||
let matchedNavItem: NavItem | undefined;
|
||||
|
||||
for (const item of navItems) {
|
||||
// Use path for matching (works for both hash URLs and regular URLs)
|
||||
const itemPath = item.path.replace(/^\//, '');
|
||||
if (itemPath === firstSegment || (itemPath === '' && firstSegment === undefined)) {
|
||||
matchedNavItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedNavItem) {
|
||||
// Try to match by checking if pathname matches the item path
|
||||
for (const item of navItems) {
|
||||
const fullItemPath = `${currentOrgPath}${item.path}`;
|
||||
if (isActive(pathname, fullItemPath)) {
|
||||
matchedNavItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedNavItem) {
|
||||
// Handle hash URLs (like settings) - use the actual path instead of '#'
|
||||
const href = matchedNavItem.useHashUrl ? `${currentOrgPath}${matchedNavItem.path}` : matchedNavItem.url;
|
||||
|
||||
breadcrumbs.push({
|
||||
label: matchedNavItem.title,
|
||||
href: href
|
||||
});
|
||||
}
|
||||
|
||||
// Handle items with query params (like settings tabs)
|
||||
if (matchedNavItem?.items && searchParams) {
|
||||
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, true));
|
||||
if (subItem) {
|
||||
breadcrumbs.push({
|
||||
label: subItem.title,
|
||||
href: subItem.url
|
||||
});
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested routes
|
||||
if (pathSegments.length > 1 && matchedNavItem) {
|
||||
const secondSegment = pathSegments[1];
|
||||
|
||||
// Check for static nested routes (like community/ask, settings/customize-lms)
|
||||
if (matchedNavItem.nestedRoutes) {
|
||||
const nestedRoute = matchedNavItem.nestedRoutes.find((route) => route.path === secondSegment);
|
||||
if (nestedRoute) {
|
||||
breadcrumbs.push({
|
||||
label: nestedRoute.titleKey, // Could be translated if needed
|
||||
href: `${currentOrgPath}${matchedNavItem.path}/${nestedRoute.path}`
|
||||
});
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dynamic segments (like community/[slug], quiz/[slug])
|
||||
if (matchedNavItem.supportsDynamicSegment && secondSegment) {
|
||||
// Check if it's not a static nested route
|
||||
const isStaticRoute = matchedNavItem.nestedRoutes?.some((route) => route.path === secondSegment);
|
||||
if (!isStaticRoute && pageData?.breadcrumb) {
|
||||
breadcrumbs.push({
|
||||
label: pageData.breadcrumb,
|
||||
href: `${currentOrgPath}${matchedNavItem.path}/${secondSegment}`
|
||||
});
|
||||
}
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import CogIcon from '@lucide/svelte/icons/cog';
|
||||
import type { Component } from 'svelte';
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
export interface NavItem {
|
||||
title: string; // translation key
|
||||
url: string;
|
||||
path: string; // Actual path (e.g., '/settings') for breadcrumb generation
|
||||
icon?: Component;
|
||||
isActive?: boolean;
|
||||
isExpanded?: boolean;
|
||||
items?: NavItem[]; // for nested items like settings
|
||||
// Metadata for breadcrumb generation
|
||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||
nestedRoutes?: NestedRouteConfig[]; // Static nested routes (like community/ask, settings/customize-lms)
|
||||
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
||||
}
|
||||
|
||||
export interface NavItemConfig {
|
||||
titleKey: string;
|
||||
path: string;
|
||||
icon?: Component;
|
||||
requiresAdmin?: boolean;
|
||||
items?: NavItemConfig[];
|
||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||
nestedRoutes?: NestedRouteConfig[]; // Static nested routes
|
||||
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
||||
}
|
||||
|
||||
export interface NestedRouteConfig {
|
||||
path: string; // Relative to parent (e.g., 'ask', 'customize-lms')
|
||||
titleKey: string; // Translation key or plain text
|
||||
}
|
||||
|
||||
// Base navigation configuration structure
|
||||
const baseNavConfig: NavItemConfig[] = [
|
||||
{
|
||||
titleKey: 'org_navigation.dashboard',
|
||||
path: '',
|
||||
icon: LayoutDashboardIcon
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.courses',
|
||||
path: '/courses',
|
||||
icon: LibraryBigIcon
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.community',
|
||||
path: '/community',
|
||||
icon: MessageSquareMoreIcon,
|
||||
supportsDynamicSegment: true, // Supports /community/[slug]
|
||||
nestedRoutes: [
|
||||
{
|
||||
path: 'ask',
|
||||
titleKey: 'Ask Question' // Could be translated
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.audience',
|
||||
path: '/audience',
|
||||
icon: UsersIcon
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.setup',
|
||||
path: '/setup',
|
||||
icon: Settings2Icon,
|
||||
requiresAdmin: true
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.settings',
|
||||
path: '/settings',
|
||||
icon: CogIcon,
|
||||
useHashUrl: true, // Use '#' for collapsible parent
|
||||
items: [
|
||||
{
|
||||
titleKey: 'Profile',
|
||||
path: '/settings'
|
||||
},
|
||||
{
|
||||
titleKey: 'Organization',
|
||||
path: '/settings?tab=org'
|
||||
},
|
||||
{
|
||||
titleKey: 'Landing Page',
|
||||
path: '/settings?tab=landingpage'
|
||||
},
|
||||
{
|
||||
titleKey: 'Billing',
|
||||
path: '/settings?tab=billing'
|
||||
}
|
||||
],
|
||||
nestedRoutes: [
|
||||
{
|
||||
path: 'customize-lms',
|
||||
titleKey: 'Customize LMS'
|
||||
},
|
||||
{
|
||||
path: 'domains',
|
||||
titleKey: 'Domains'
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
titleKey: 'Teams'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Get navigation items based on organization context and permissions
|
||||
*/
|
||||
export function getOrgNavigationItems(
|
||||
currentOrgPath: string,
|
||||
currentOrg: AccountOrg,
|
||||
isOrgAdmin: boolean | null,
|
||||
t: (key: string) => string,
|
||||
pagePathname: string
|
||||
): NavItem[] {
|
||||
const items: NavItem[] = [];
|
||||
|
||||
for (const config of baseNavConfig) {
|
||||
// Skip admin-only items if user is not admin
|
||||
if (config.requiresAdmin && !isOrgAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
|
||||
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
|
||||
|
||||
const item: NavItem = {
|
||||
title: t(config.titleKey),
|
||||
url: config.useHashUrl ? '#' : url,
|
||||
path: config.path, // Store actual path for breadcrumb generation
|
||||
icon: config.icon,
|
||||
isActive: isActive(pagePathname, fullPath),
|
||||
isExpanded: config.items ? isActive(pagePathname, fullPath) : undefined,
|
||||
useHashUrl: config.useHashUrl,
|
||||
nestedRoutes: config.nestedRoutes,
|
||||
supportsDynamicSegment: config.supportsDynamicSegment
|
||||
};
|
||||
|
||||
// Handle nested items (like settings sub-items)
|
||||
if (config.items) {
|
||||
item.items = config.items.map((subConfig) => ({
|
||||
title: subConfig.titleKey,
|
||||
url: `${currentOrgPath}${subConfig.path}`,
|
||||
path: subConfig.path
|
||||
}));
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
A Progress Bar that hooks to SvelteKit navigation.
|
||||
|
||||
The progress bar will show during navigation if loading takes more than `displayThresholdMs`
|
||||
and will hide when navigation is complete.
|
||||
https://github.com/prgm-dev/sveltekit-progress-bar
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
// https://github.com/saibotsivad/svelte-progress-bar
|
||||
id?: string | undefined;
|
||||
viewTransitionName?: string;
|
||||
busy?: boolean;
|
||||
color?: string;
|
||||
textColorClass?: `text-${string}` | '';
|
||||
zIndex?: number;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
settleTime?: number;
|
||||
intervalTime?: number;
|
||||
stepSizes?: any;
|
||||
displayThresholdMs?: number;
|
||||
noNavigationProgress?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
id = undefined,
|
||||
viewTransitionName = 'sveltekit-progress-bar',
|
||||
busy = $bindable(false),
|
||||
color = 'currentColor',
|
||||
textColorClass = '',
|
||||
zIndex = 1,
|
||||
minimum = 0.08,
|
||||
maximum = 0.994,
|
||||
settleTime = 700,
|
||||
intervalTime = 700,
|
||||
stepSizes = [0, 0.005, 0.01, 0.02],
|
||||
displayThresholdMs = 150,
|
||||
noNavigationProgress = false
|
||||
}: Props = $props();
|
||||
|
||||
// Towards the end of the progress bar animation, we want to shorten the increment
|
||||
// step size, to give it the appearance of slowing down. This indicates to the user
|
||||
// that progress is still happening, but not as fast as they might like.
|
||||
const getIncrement = (n: number) => {
|
||||
if (n >= 0 && n < 0.2) return 0.1;
|
||||
else if (n >= 0.2 && n < 0.5) return 0.04;
|
||||
else if (n >= 0.5 && n < 0.8) return 0.02;
|
||||
else if (n >= 0.8 && n < 0.99) return 0.005;
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Internal private state.
|
||||
let running = $state(false);
|
||||
let updater: ReturnType<typeof setInterval> | null = null;
|
||||
let completed = $state(false);
|
||||
let width = $state(0);
|
||||
|
||||
// Update the busy prop when the internal `running` variable changes.
|
||||
// run(() => {
|
||||
// busy = running;
|
||||
// });
|
||||
|
||||
export const reset = (min = minimum) => {
|
||||
width = min;
|
||||
running = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Continue the animation of the progress bar from whatever position it is in, using
|
||||
* a randomized step size to increment.
|
||||
*/
|
||||
export const animate = () => {
|
||||
if (updater) {
|
||||
// prevent multiple intervals by clearing before making
|
||||
clearInterval(updater);
|
||||
}
|
||||
running = true;
|
||||
updater = setInterval(() => {
|
||||
const randomStep = stepSizes[Math.floor(Math.random() * stepSizes.length)] ?? 0;
|
||||
const step = getIncrement(width) + randomStep;
|
||||
if (width < maximum) {
|
||||
width = width + step;
|
||||
}
|
||||
if (width > maximum) {
|
||||
width = maximum;
|
||||
stop();
|
||||
}
|
||||
}, intervalTime);
|
||||
};
|
||||
|
||||
/** Restart the bar at the minimum, and begin the auto-increment progress. */
|
||||
export const start = (min?: number) => {
|
||||
reset(min);
|
||||
animate();
|
||||
};
|
||||
|
||||
/** Stop the progress bar from incrementing, but leave it visible. */
|
||||
export const stop = () => {
|
||||
if (updater) {
|
||||
clearInterval(updater);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves the progress bar to the fully completed position, wait an appropriate
|
||||
* amount of time so the user can feel the completion, then hide and reset.
|
||||
*/
|
||||
export const complete = (settle = settleTime) => {
|
||||
if (updater) clearInterval(updater);
|
||||
if (!running) return;
|
||||
width = 1;
|
||||
running = false;
|
||||
setTimeout(() => {
|
||||
// complete the bar first
|
||||
completed = true;
|
||||
setTimeout(() => {
|
||||
// after some time (long enough to finish the hide animation) reset it back to 0
|
||||
completed = false;
|
||||
width = 0;
|
||||
}, settle);
|
||||
}, settle);
|
||||
};
|
||||
|
||||
/** Stop the auto-increment functionality and manually set the width of the progress bar. */
|
||||
export const setWidthRatio = (widthRatio: typeof width) => {
|
||||
stop();
|
||||
width = widthRatio;
|
||||
completed = false;
|
||||
running = true;
|
||||
};
|
||||
|
||||
// Primarily used for tests, but can also be used for external monitoring.
|
||||
export const getState = () => {
|
||||
return {
|
||||
width,
|
||||
running,
|
||||
completed,
|
||||
color,
|
||||
minimum,
|
||||
maximum,
|
||||
settleTime,
|
||||
intervalTime,
|
||||
stepSizes
|
||||
};
|
||||
};
|
||||
|
||||
let barStyle = $derived(
|
||||
(color ? `background-color: ${color};` : '') +
|
||||
(width && width * 100 ? `width: ${width * 100}%;` : '') +
|
||||
`z-index: ${zIndex};` +
|
||||
`view-transition-name: ${viewTransitionName}-bar;`
|
||||
);
|
||||
|
||||
// the box shadow of the leader bar uses `color` to set its shadow color
|
||||
let leaderColorStyle = $derived(
|
||||
(color ? `background-color: ${color}; color: ${color};` : '') +
|
||||
`z-index: ${zIndex + 1};` +
|
||||
`view-transition-name: ${viewTransitionName}-leader;`
|
||||
);
|
||||
|
||||
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
beforeNavigate((nav) => {
|
||||
if (progressBarStartTimeout) {
|
||||
clearTimeout(progressBarStartTimeout);
|
||||
progressBarStartTimeout = null;
|
||||
}
|
||||
if (noNavigationProgress) return;
|
||||
|
||||
if (nav.to?.route.id) {
|
||||
// Internal navigation.
|
||||
if (displayThresholdMs > 0) {
|
||||
// Schedule a display of the progress bar in `displayThresholdMs` milliseconds.
|
||||
// This is to avoid flickering/flashing when the navigation is fast.
|
||||
progressBarStartTimeout = setTimeout(() => !noNavigationProgress && start(), displayThresholdMs);
|
||||
} else start();
|
||||
|
||||
nav.complete.catch().finally(() => {
|
||||
if (progressBarStartTimeout) {
|
||||
clearTimeout(progressBarStartTimeout);
|
||||
progressBarStartTimeout = null;
|
||||
}
|
||||
complete();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if running || width > 0}
|
||||
<output
|
||||
{id}
|
||||
role="progressbar"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={1}
|
||||
class="svelte-progress-bar {textColorClass}"
|
||||
class:running
|
||||
class:svelte-progress-bar-hiding={completed}
|
||||
style={barStyle}
|
||||
>
|
||||
{#if running}
|
||||
<div class="svelte-progress-bar-leader" style={leaderColorStyle}></div>
|
||||
{/if}
|
||||
</output>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.svelte-progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
transition: width 0.21s ease-in-out;
|
||||
}
|
||||
|
||||
.svelte-progress-bar-hiding {
|
||||
transition: top 0.8s ease;
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.svelte-progress-bar-leader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 5px;
|
||||
width: 100px;
|
||||
transform: rotate(2.5deg) translate(0px, -4px);
|
||||
box-shadow: 0 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import * as Kbd from '@cio/ui/base/kbd';
|
||||
import * as InputGroup from '@cio/ui/base/input-group';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
</script>
|
||||
|
||||
<InputGroup.Root class="hidden! md:flex! max-w-56">
|
||||
<InputGroup.Input placeholder="Search..." />
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon />
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<Kbd.Root>⌘</Kbd.Root>
|
||||
<Kbd.Root>K</Kbd.Root>
|
||||
</InputGroup.Addon>
|
||||
</InputGroup.Root>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SidebarFooterMenu } from './menu.svelte';
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
|
||||
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import ThemeToggle from './theme-toggle.svelte';
|
||||
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { logout } from '$lib/utils/functions/logout';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
|
||||
SNIPPETS
|
||||
|
||||
-->
|
||||
{#snippet avatarblock()}
|
||||
<Avatar.Root>
|
||||
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname} />
|
||||
<Avatar.Fallback class="rounded-lg">{$profile.fullname.slice(0, 2)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm font-normal leading-tight">
|
||||
<span class="truncate">{$profile.fullname}</span>
|
||||
<span class="truncate text-xs">{$profile.email}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet usertrigger()}
|
||||
<DropdownMenu.Label class="p-0">
|
||||
<a
|
||||
href={`${!$globalStore.isOrgSite ? $currentOrgPath : '/lms'}/settings`}
|
||||
class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"
|
||||
>
|
||||
{@render avatarblock()}
|
||||
</a>
|
||||
</DropdownMenu.Label>
|
||||
{/snippet}
|
||||
|
||||
{#snippet themetoggle()}
|
||||
<DropdownMenu.Label class="p-0 font-normal">
|
||||
<ThemeToggle />
|
||||
</DropdownMenu.Label>
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
|
||||
COMPONENT
|
||||
|
||||
-->
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<!-- DROPDOWN TRIGGER -->
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
size="lg"
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
{...props}
|
||||
>
|
||||
{@render avatarblock()}
|
||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<!-- DROPDOWN CONTENT -->
|
||||
<DropdownMenu.Content
|
||||
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
{@render usertrigger()}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
{@render themetoggle()}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Group>
|
||||
<div class="cursor-pointer space-y-2">
|
||||
{#if !$globalStore.isOrgSite}
|
||||
<div class="space-y-4">
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href="https://classroomio.com/roadmap" target="_blank" class="flex w-full items-center gap-2">
|
||||
<BellPlusIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.whats_new')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a
|
||||
href="https://classroomio.com/blog/launch-week"
|
||||
target="_blank"
|
||||
class="flex w-full items-center gap-2"
|
||||
>
|
||||
<RocketIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.launch_week')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a
|
||||
href="https://classroomio.com/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex h-full w-full items-center justify-start space-x-2 text-start"
|
||||
>
|
||||
<BadgeHelpIcon size={20} />
|
||||
<p class="text-sm dark:text-white">{$t('org_navigation.help')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onclick={() => logout()}>
|
||||
<span class="flex items-center gap-2">
|
||||
<LogOutIcon />
|
||||
<p class="text-sm">{$t('settings.profile.logout')}</p>
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MonitorIcon, SunIcon, MoonIcon } from '@lucide/svelte';
|
||||
import { userPrefersMode, resetMode, setMode } from 'mode-watcher';
|
||||
|
||||
type UserPrefersMode = typeof userPrefersMode.current;
|
||||
|
||||
const themes: { mode: UserPrefersMode; icon: typeof MonitorIcon }[] = [
|
||||
{ mode: 'light', icon: SunIcon },
|
||||
{ mode: 'dark', icon: MoonIcon },
|
||||
{ mode: 'system', icon: MonitorIcon }
|
||||
];
|
||||
|
||||
const handleThemeChange = (newMode: UserPrefersMode) => {
|
||||
if (newMode === 'light') {
|
||||
setMode('light');
|
||||
} else if (newMode === 'dark') {
|
||||
setMode('dark');
|
||||
} else {
|
||||
resetMode();
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
console.log('mode.current', userPrefersMode.current);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between gap-8">
|
||||
<p>Theme</p>
|
||||
|
||||
<div class="flex items-center gap-1 rounded-md backdrop-blur-sm">
|
||||
{#each themes as theme (theme.mode)}
|
||||
{@const isActive = userPrefersMode.current === theme.mode || userPrefersMode.current === undefined}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant={isActive ? 'outline' : 'ghost'}
|
||||
class={['size-4 rounded-md p-1 transition-all duration-200', isActive && 'shadow-primary/20 shadow-lg']}
|
||||
title={theme.mode}
|
||||
onclick={() => handleThemeChange(theme.mode)}
|
||||
>
|
||||
<theme.icon class="size-4" />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
|
||||
const planName = $derived($currentOrg.plans?.[0]?.planName || 'FREE');
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
size="sm"
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href="https://classroomio.com?utm_source={$currentOrg.siteName}.classroomio.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
<Avatar.Root class="ui:flex ui:size-6 ui:items-center ui:justify-center">
|
||||
<Avatar.Image src={'/logo-192.png'} alt="ClassroomIO logo" />
|
||||
</Avatar.Root>
|
||||
|
||||
<span class="truncate font-normal">ClassroomIO</span>
|
||||
<Badge variant="outline" class="capitalize">
|
||||
{planName.toLowerCase()}
|
||||
</Badge>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OrgSidebar } from './org-sidebar.svelte';
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { currentOrgPath, currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import * as Collapsible from '@cio/ui/base/collapsible';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
import { page } from '$app/state';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { getOrgNavigationItems } from '$lib/features/ui/navigation/org-navigation';
|
||||
|
||||
const items = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
|
||||
|
||||
const pathWithQuery = $derived(page.url.pathname + page.url.search);
|
||||
</script>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>{$t('org_navigation.platform')}</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
{#if item.items}
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{#if item.items}
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
{#each item.items ?? [] as subItem (subItem.title)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, true)}>
|
||||
{#snippet child({ props })}
|
||||
<a href={subItem.url} {...props}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
{/if}
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
|
||||
import AppLogo from './app-logo.svelte';
|
||||
import NavMain from './nav-main.svelte';
|
||||
import { SidebarFooterMenu } from '../footer';
|
||||
import UpgradeTrigger from './upgrade-trigger.svelte';
|
||||
import SidebarSkeleton from '../sidebar-skeleton.svelte';
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
</script>
|
||||
|
||||
{#if !isOrgLoaded}
|
||||
<SidebarSkeleton />
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Header>
|
||||
<AppLogo />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
<NavMain />
|
||||
<UpgradeTrigger />
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Footer>
|
||||
<SidebarFooterMenu />
|
||||
</Sidebar.Footer>
|
||||
|
||||
<Sidebar.Rail />
|
||||
</Sidebar.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { getShortOrgName } from '$lib/utils/functions/org';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
|
||||
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
interface Props {
|
||||
variant?: 'sidebar' | 'breadcrumb';
|
||||
}
|
||||
|
||||
let { variant = 'sidebar' }: Props = $props();
|
||||
|
||||
function onClick(org: AccountOrg) {
|
||||
if (org.id === $currentOrg.id) return;
|
||||
|
||||
localStorage.setItem('classroomio_org_sitename', org.siteName);
|
||||
currentOrg.set(org);
|
||||
|
||||
setTheme(org.theme);
|
||||
window.location.href = $currentOrgPath;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if variant === 'breadcrumb'}
|
||||
<!-- Breadcrumb context version -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Breadcrumb.Link {...props} href="##" class="flex items-center gap-2">
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-md! text-xs">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
|
||||
<ChevronDownIcon class="ml-auto hidden size-4 md:block" />
|
||||
{:else}
|
||||
<Skeleton class="h-4 w-24" />
|
||||
{/if}
|
||||
</Breadcrumb.Link>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
|
||||
align="start"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
|
||||
|
||||
{#each $orgs as org (org.name)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item class="cursor-not-allowed gap-2 p-2 opacity-50">
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<PlusIcon class="size-4" />
|
||||
</div>
|
||||
<div class="ui:text-muted-foreground ui:font-normal">Add Organization</div>
|
||||
|
||||
<ComingSoon />
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<!-- Sidebar context version -->
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
size="lg"
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-normal">
|
||||
{$currentOrg.name}
|
||||
</span>
|
||||
<span class="truncate text-xs">
|
||||
{$currentOrg.plans?.[0]?.planName || 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon class="ml-auto" />
|
||||
{:else}
|
||||
<div class="h-[30px] w-[219px]">
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
|
||||
align="start"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
|
||||
|
||||
{#each $orgs as org (org.name)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item class="cursor-not-allowed gap-2 p-2 opacity-50">
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<PlusIcon class="size-4" />
|
||||
</div>
|
||||
<div class="ui:text-muted-foreground ui:font-normal">Add Organization</div>
|
||||
|
||||
<ComingSoon />
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Button } from '$src/base/button';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
{#if $isFreePlan}
|
||||
<Sidebar.Content>
|
||||
{#if sidebar.open || sidebar.isMobile}
|
||||
<div
|
||||
class="mx-4 mt-2 flex flex-col items-center justify-center gap-4 rounded-md border border-blue-600 px-2 py-6 text-center transition-all ease-in-out hover:border-blue-800"
|
||||
>
|
||||
<SparklesIcon class="my-3 size-6" />
|
||||
<span class="flex flex-col gap-1">
|
||||
<p class="text-base font-semibold">{$t('org_navigation.early_adopter')}</p>
|
||||
<p class="text-xs">{$t('org_navigation.unlock')}</p>
|
||||
</span>
|
||||
<Button
|
||||
data-sidebar="upgrade-trigger"
|
||||
data-slot="upgrade-trigger"
|
||||
class="font-normal"
|
||||
type="button"
|
||||
onclick={openUpgradeModal}
|
||||
>
|
||||
<SparklesIcon class="custom size-4" />
|
||||
{$t('org_navigation.upgrade')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
data-sidebar="upgrade-trigger"
|
||||
data-slot="upgrade-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="mx-1 flex w-full items-center justify-center"
|
||||
type="button"
|
||||
onclick={openUpgradeModal}
|
||||
>
|
||||
<SparklesIcon class="custom size-4" />
|
||||
<span class="sr-only">{$t('org_navigation.upgrade')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
{/if}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="bg-sidebar flex h-[calc(100vh-48px)] w-64 flex-col gap-2 border-r p-2">
|
||||
<!-- org switcher -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-8 w-8 rounded-lg" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<!-- nav main -->
|
||||
<div class="flex flex-col gap-1 py-2">
|
||||
<Skeleton class="mb-2 h-3 w-16 px-2" />
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- upgrade banner -->
|
||||
<div class="mx-4 mb-2 flex flex-col items-center gap-3 rounded-md border p-4">
|
||||
<Skeleton class="h-16 w-16 rounded-md" />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
<Skeleton class="h-3 w-40" />
|
||||
</div>
|
||||
<Skeleton class="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- nav user -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-8 w-8 rounded-lg" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user