feat: migrate course UI from supabase (#615)

* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <[email protected]>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <[email protected]>
This commit is contained in:
Rotimi Best
2025-12-22 02:36:48 +01:00
committed by GitHub
co-authored by Abdulmuizz Hamzat
parent e6a3cf9332
commit 9fb6c7cbaa
401 changed files with 10489 additions and 11679 deletions
@@ -4,18 +4,20 @@
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 Search from '$features/ui/search.svelte';
import AppBreadcrumbs from './app-breadcrumbs.svelte';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import AppSetup from './app-setup.svelte';
const siteName = $derived($currentOrg.siteName);
$effect(() => {
if ($currentOrg.siteName) {
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName);
});
</script>
@@ -34,7 +36,6 @@
<span class="grow"></span>
<AppSetup />
<Search />
<Popover.Root>
@@ -3,12 +3,12 @@
import { Button } from '@cio/ui/base/button';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import CircleIcon from '@lucide/svelte/icons/circle';
import SetupProgress from '$lib/features/setup/components/setup-progress.svelte';
import SetupProgress from '$features/setup/components/setup-progress.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import { calculateProgress, setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import { calculateProgress, setupProgressApi } from '$features/setup/api/setup-progress.svelte';
function handleRefresh() {
if ($currentOrg.siteName) {
@@ -0,0 +1,78 @@
/**
* Pre-configured breadcrumb generators for different contexts
* These wrap the core generator with context-specific configurations
*/
import type { BreadcrumbConfig } from './breadcrumb-core';
import type { NavItem as LmsNavItem } from './lms-navigation';
import type { NavItem } from './org-navigation';
/**
* Configuration for Org breadcrumbs
*/
export function createOrgBreadcrumbConfig(
currentOrgPath: string,
pageData?: { breadcrumb?: string }
): BreadcrumbConfig {
return {
pathPrefix: (pathname: string) => {
// Extract org path dynamically (e.g., '/org/myorg')
const match = pathname.match(/^\/org\/[^/]+/);
return match ? match[0] : '';
},
basePath: currentOrgPath,
dynamicTitleResolver: (segment, parentPath, fullPathname) => {
// Use page.data.breadcrumb for server-side resolved titles
return pageData?.breadcrumb || null;
},
enableCache: true,
maxDepth: 10
};
}
/**
* Configuration for LMS breadcrumbs
*/
export function createLmsBreadcrumbConfig(
dynamicTitleResolver?: (segment: string, parentPath: string, fullPathname: string) => string | null
): BreadcrumbConfig {
return {
pathPrefix: '/lms',
basePath: '/lms',
dynamicTitleResolver: dynamicTitleResolver || null,
enableCache: true,
maxDepth: 10
};
}
/**
* Helper to create LMS breadcrumb config with store-based resolver
* The getStoreValue function will be called reactively in Svelte components
*/
export function createLmsBreadcrumbConfigWithStore(getStoreValue: () => string | null | undefined): BreadcrumbConfig {
return createLmsBreadcrumbConfig((segment, parentPath, fullPathname) => {
return getStoreValue() || null;
});
}
/**
* Helper to create Org breadcrumb config with store-based resolver
* The getStoreValue function will be called reactively in Svelte components
*/
export function createOrgBreadcrumbConfigWithStore(
currentOrgPath: string,
getStoreValue: () => string | null | undefined
): BreadcrumbConfig {
return {
pathPrefix: (pathname: string) => {
const match = pathname.match(/^\/org\/[^/]+/);
return match ? match[0] : '';
},
basePath: currentOrgPath,
dynamicTitleResolver: (segment, parentPath, fullPathname) => {
return getStoreValue() || null;
},
enableCache: false, // Disable cache for reactive stores
maxDepth: 10
};
}
@@ -0,0 +1,563 @@
import { isActive } from '$lib/utils/functions/app';
/**
* Unified breadcrumb system that addresses all scalability limitations
*/
export interface BreadcrumbItem {
label: string;
href: string;
metadata?: Record<string, unknown>;
}
export interface NestedRouteConfig {
path: string;
titleKey: string;
// Optional: Custom resolver for dynamic titles
titleResolver?: (params: Record<string, string>) => string | Promise<string>;
// Optional: Nested routes can have their own nested routes (multi-level support)
nestedRoutes?: NestedRouteConfig[];
// Optional: Supports dynamic segments within nested routes
supportsDynamicSegment?: boolean;
}
export interface NavItem {
title: string;
url: string;
path: string;
icon?: unknown;
isActive?: boolean;
isExpanded?: boolean;
items?: NavItem[];
useHashUrl?: boolean;
nestedRoutes?: NestedRouteConfig[];
supportsDynamicSegment?: boolean;
// NEW: Route-specific breadcrumb overrides
breadcrumbLabel?: string;
breadcrumbResolver?: (params: Record<string, string>) => string | Promise<string>;
}
/**
* Configuration for breadcrumb generation context
*/
export interface BreadcrumbConfig {
/**
* Path prefix to remove from pathname (e.g., '/lms', '/org/[slug]')
* Can be a string or a function that extracts the prefix dynamically
*/
pathPrefix: string | ((pathname: string) => string);
/**
* Base path for generating hrefs (e.g., '/lms', '/org/myorg')
*/
basePath: string | ((pathname: string) => string);
/**
* Resolver for dynamic segment titles
* Can resolve from page.data, stores, or API calls
*/
dynamicTitleResolver?: (
segment: string,
parentPath: string,
fullPathname: string,
context?: unknown
) => string | Promise<string> | null | undefined;
/**
* Optional: Cache breadcrumbs for better performance
*/
enableCache?: boolean;
/**
* Optional: Maximum breadcrumb depth (prevents infinite recursion)
*/
maxDepth?: number;
}
/**
* Cache for breadcrumb generation (simple in-memory cache)
*/
const breadcrumbCache = new Map<string, BreadcrumbItem[]>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const cacheTimestamps = new Map<string, number>();
/**
* Clear expired cache entries
*/
function clearExpiredCache() {
const now = Date.now();
for (const [key, timestamp] of cacheTimestamps.entries()) {
if (now - timestamp > CACHE_TTL) {
breadcrumbCache.delete(key);
cacheTimestamps.delete(key);
}
}
}
/**
* Get cached breadcrumbs or generate new ones
*/
function getCachedOrGenerate(
cacheKey: string,
generator: () => BreadcrumbItem[],
enableCache: boolean
): BreadcrumbItem[] {
if (!enableCache) {
return generator();
}
clearExpiredCache();
const cached = breadcrumbCache.get(cacheKey);
if (cached) {
return cached;
}
const result = generator();
breadcrumbCache.set(cacheKey, result);
cacheTimestamps.set(cacheKey, Date.now());
return result;
}
/**
* Extract path prefix from pathname
*/
function extractPathPrefix(
pathname: string,
prefixConfig: string | ((pathname: string) => string)
): string {
if (typeof prefixConfig === 'function') {
return prefixConfig(pathname);
}
// Handle dynamic prefixes like '/org/[slug]'
if (prefixConfig.includes('[')) {
const pattern = prefixConfig.replace(/\[.*?\]/g, '[^/]+');
const match = pathname.match(new RegExp(`^${pattern}`));
return match ? match[0] : '';
}
return pathname.startsWith(prefixConfig) ? prefixConfig : '';
}
/**
* Get base path for href generation
*/
function getBasePath(
pathname: string,
basePathConfig: string | ((pathname: string) => string)
): string {
if (typeof basePathConfig === 'function') {
return basePathConfig(pathname);
}
return basePathConfig;
}
/**
* Find matching navigation item for a path segment
*/
function findMatchingNavItem(
segment: string,
navItems: NavItem[],
fullPathname: string,
basePath: string
): NavItem | undefined {
// First try exact path match
for (const item of navItems) {
const itemPath = item.path.replace(/^\//, '');
if (itemPath === segment || (itemPath === '' && segment === undefined)) {
return item;
}
}
// Fallback: use isActive matching
for (const item of navItems) {
const fullItemPath = `${basePath}${item.path}`;
if (isActive(fullPathname, fullItemPath)) {
return item;
}
}
return undefined;
}
/**
* Resolve dynamic title for a segment
*/
async function resolveDynamicTitle(
segment: string,
parentPath: string,
fullPathname: string,
navItem: NavItem,
config: BreadcrumbConfig,
context?: unknown
): Promise<string | null> {
// Check for route-specific resolver first
if (navItem.breadcrumbResolver) {
const params = extractParamsFromPath(fullPathname, parentPath);
const result = navItem.breadcrumbResolver(params);
return result instanceof Promise ? await result : result;
}
// Check for nested route resolver
if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => {
// Check if this is a dynamic segment
if (route.supportsDynamicSegment) {
return true;
}
return route.path === segment;
});
if (nestedRoute?.titleResolver) {
const params = extractParamsFromPath(fullPathname, parentPath);
const result = nestedRoute.titleResolver(params);
return result instanceof Promise ? await result : result;
}
}
// Use global dynamic title resolver
if (config.dynamicTitleResolver) {
const result = config.dynamicTitleResolver(segment, parentPath, fullPathname, context);
return result instanceof Promise ? await result : result || null;
}
return null;
}
/**
* Extract parameters from pathname (e.g., { slug: 'my-post' })
*/
function extractParamsFromPath(
pathname: string,
parentPath: string
): Record<string, string> {
const params: Record<string, string> = {};
const relativePath = pathname.replace(parentPath, '').replace(/^\//, '');
const segments = relativePath.split('/').filter(Boolean);
// Simple extraction - can be enhanced for more complex patterns
segments.forEach((segment, index) => {
params[`param${index}`] = segment;
});
return params;
}
/**
* Recursively generate breadcrumbs for nested routes
*/
async function generateNestedBreadcrumbs(
segments: string[],
navItem: NavItem,
currentPath: string,
basePath: string,
fullPathname: string,
searchParams: string,
config: BreadcrumbConfig,
context?: unknown,
depth: number = 0
): Promise<BreadcrumbItem[]> {
const breadcrumbs: BreadcrumbItem[] = [];
const maxDepth = config.maxDepth ?? 10;
if (depth >= maxDepth || segments.length === 0) {
return breadcrumbs;
}
const currentSegment = segments[0];
const remainingSegments = segments.slice(1);
const segmentPath = `${currentPath}/${currentSegment}`;
// Check for static nested route
if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey,
href: `${basePath}${segmentPath}`
});
// Recursively handle nested routes within nested routes
if (remainingSegments.length > 0 && nestedRoute.nestedRoutes) {
const nestedBreadcrumbs = await generateNestedBreadcrumbs(
remainingSegments,
{ ...navItem, nestedRoutes: nestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
return breadcrumbs;
}
}
// Handle dynamic segment
if (navItem.supportsDynamicSegment ||
navItem.nestedRoutes?.some((route) => route.supportsDynamicSegment && route.path !== currentSegment)) {
const dynamicTitle = await resolveDynamicTitle(
currentSegment,
currentPath,
fullPathname,
navItem,
config,
context
);
if (dynamicTitle) {
breadcrumbs.push({
label: dynamicTitle,
href: `${basePath}${segmentPath}`
});
// Continue with remaining segments if there are nested routes that support deeper nesting
if (remainingSegments.length > 0) {
// Check if current nav item has nested routes that support further nesting
const supportingNestedRoute = navItem.nestedRoutes?.find(
(route) => route.supportsDynamicSegment && route.nestedRoutes
);
if (supportingNestedRoute) {
const nestedBreadcrumbs = await generateNestedBreadcrumbs(
remainingSegments,
{ ...navItem, nestedRoutes: supportingNestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
}
}
}
return breadcrumbs;
}
/**
* Unified breadcrumb generator that works for all contexts
*/
export async function generateBreadcrumbs(
pathname: string,
searchParams: string,
navItems: NavItem[],
config: BreadcrumbConfig,
context?: unknown
): Promise<BreadcrumbItem[]> {
const cacheKey = `${pathname}${searchParams}`;
return getCachedOrGenerate(
cacheKey,
() => generateBreadcrumbsSync(pathname, searchParams, navItems, config, context),
config.enableCache ?? false
);
}
/**
* Synchronous version (for cases where async isn't needed)
* Note: Dynamic title resolvers that return promises won't work here
*/
export function generateBreadcrumbsSync(
pathname: string,
searchParams: string,
navItems: NavItem[],
config: BreadcrumbConfig,
context?: unknown
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
const pathWithQuery = pathname + searchParams;
// Extract path prefix and base path
const pathPrefix = extractPathPrefix(pathname, config.pathPrefix);
const basePath = getBasePath(pathname, config.basePath);
// Get relative path after removing prefix
const relativePath = pathname.replace(pathPrefix, '').replace(/^\//, '') || '';
const pathSegments = relativePath.split('/').filter(Boolean);
// Handle root/dashboard
if (!relativePath || relativePath === '') {
return breadcrumbs;
}
// Find matching top-level nav item
const firstSegment = pathSegments[0];
const matchedNavItem = findMatchingNavItem(firstSegment, navItems, pathname, basePath);
if (!matchedNavItem) {
return breadcrumbs;
}
// Add parent breadcrumb
const parentHref = matchedNavItem.useHashUrl
? `${basePath}${matchedNavItem.path}`
: matchedNavItem.url;
breadcrumbs.push({
label: matchedNavItem.breadcrumbLabel || matchedNavItem.title,
href: parentHref
});
// Handle query params (settings tabs, etc.)
if (matchedNavItem.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) =>
isActive(pathWithQuery, sub.url, undefined, true)
);
if (subItem) {
breadcrumbs.push({
label: subItem.breadcrumbLabel || subItem.title,
href: subItem.url
});
return breadcrumbs;
}
}
// Handle nested routes (recursive)
if (pathSegments.length > 1) {
const remainingSegments = pathSegments.slice(1);
const parentPath = `${basePath}${matchedNavItem.path}`;
// Note: For async resolvers, you'd need to use generateBreadcrumbs instead
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments,
matchedNavItem,
matchedNavItem.path,
basePath,
pathname,
searchParams,
config,
context
);
breadcrumbs.push(...nestedBreadcrumbs);
}
return breadcrumbs;
}
/**
* Synchronous version of nested breadcrumb generation
*/
function generateNestedBreadcrumbsSync(
segments: string[],
navItem: NavItem,
currentPath: string,
basePath: string,
fullPathname: string,
searchParams: string,
config: BreadcrumbConfig,
context?: unknown,
depth: number = 0
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
const maxDepth = config.maxDepth ?? 10;
if (depth >= maxDepth || segments.length === 0) {
return breadcrumbs;
}
const currentSegment = segments[0];
const remainingSegments = segments.slice(1);
const segmentPath = `${currentPath}/${currentSegment}`;
// Check for static nested route
if (navItem.nestedRoutes) {
const nestedRoute = navItem.nestedRoutes.find((route) => route.path === currentSegment);
if (nestedRoute) {
breadcrumbs.push({
label: nestedRoute.titleKey,
href: `${basePath}${segmentPath}`
});
// Recursively handle nested routes
if (remainingSegments.length > 0 && nestedRoute.nestedRoutes) {
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments,
{ ...navItem, nestedRoutes: nestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
return breadcrumbs;
}
}
// Handle dynamic segment
if (navItem.supportsDynamicSegment) {
// Try to resolve title synchronously (from context if available)
let dynamicTitle: string | null = null;
if (config.dynamicTitleResolver) {
const result = config.dynamicTitleResolver(
currentSegment,
currentPath,
fullPathname,
context
);
// Only use if it's not a promise
if (typeof result === 'string') {
dynamicTitle = result;
}
}
if (dynamicTitle) {
breadcrumbs.push({
label: dynamicTitle,
href: `${basePath}${segmentPath}`
});
// Continue with remaining segments
if (remainingSegments.length > 0) {
const supportingNestedRoute = navItem.nestedRoutes?.find(
(route) => route.supportsDynamicSegment && route.nestedRoutes
);
if (supportingNestedRoute?.nestedRoutes) {
const nestedBreadcrumbs = generateNestedBreadcrumbsSync(
remainingSegments,
{ ...navItem, nestedRoutes: supportingNestedRoute.nestedRoutes } as NavItem,
segmentPath,
basePath,
fullPathname,
searchParams,
config,
context,
depth + 1
);
breadcrumbs.push(...nestedBreadcrumbs);
}
}
}
}
return breadcrumbs;
}
/**
* Clear breadcrumb cache (useful for testing or manual cache invalidation)
*/
export function clearBreadcrumbCache() {
breadcrumbCache.clear();
cacheTimestamps.clear();
}
@@ -0,0 +1,108 @@
import type { NavItem } from './lms-navigation';
import { isActive } from '$lib/utils/functions/app';
export interface BreadcrumbItem {
label: string;
href: string;
}
/**
* Generate breadcrumbs from the current pathname using LMS navigation structure
*/
export function generateLmsBreadcrumbs(
pathname: string,
searchParams: string,
navItems: NavItem[],
pageData?: { breadcrumb?: string } // Optional page data for dynamic titles
): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
const pathWithQuery = pathname + searchParams;
// Remove the /lms prefix to get the relative path
const relativePath = pathname.replace(/^\/lms/, '').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 = `/lms${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 ? `/lms${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, undefined, 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/integrations)
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: `/lms${matchedNavItem.path}/${nestedRoute.path}`
});
return breadcrumbs;
}
}
// Check for dynamic segments (like community/[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: `/lms${matchedNavItem.path}/${secondSegment}`
});
}
return breadcrumbs;
}
}
return breadcrumbs;
}
@@ -0,0 +1,101 @@
<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 { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { getLmsNavigationItems } from './lms-navigation';
import { generateLmsBreadcrumbs } from './lms-breadcrumb';
import { currentCommunityQuestion } from '$features/community/utils/store';
const navItems = $derived(getLmsNavigationItems($currentOrg, $t, page.url.pathname));
// Get breadcrumb title from store for LMS community posts
const breadcrumbTitle = $derived($currentCommunityQuestion.title);
const breadcrumbs = $derived(
generateLmsBreadcrumbs(
page.url.pathname,
page.url.search,
navItems,
breadcrumbTitle ? { breadcrumb: breadcrumbTitle } : undefined
)
);
let open = $state(false);
const isDesktop = new MediaQuery('(min-width: 768px)');
const ITEMS_TO_DISPLAY = $derived(isDesktop.current ? 2 : 1);
</script>
<Breadcrumb.Root>
<Breadcrumb.List class="flex-nowrap!">
{#if breadcrumbs.length > 0}
{#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,63 @@
<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 LmsBreadcrumbs from './lms-breadcrumbs.svelte';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
interface Props {
hideSearch?: boolean;
}
let { hideSearch = false }: Props = $props();
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 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>
<LmsBreadcrumbs />
<span class="grow"></span>
{#if !hideSearch}
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
<BellIcon class="custom rounded-full" />
</Button>
</Popover.Trigger>
<Popover.Content>
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
<Empty.Header>
<Empty.Media variant="icon">
<BellIcon />
</Empty.Media>
<Empty.Title>No Notifications</Empty.Title>
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
{/if}
</div>
</header>
@@ -0,0 +1,147 @@
import type { AccountOrg } from '$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 ListTodoIcon from '@lucide/svelte/icons/list-todo';
import MapPlusIcon from '@lucide/svelte/icons/map-plus';
import UsersIcon from '@lucide/svelte/icons/users';
import { isActive } from '$lib/utils/functions/app';
export interface NavItem {
title: string;
url: string;
path: string;
icon?: Component;
isActive?: boolean;
isExpanded?: boolean;
show?: () => boolean;
items?: NavItem[];
useHashUrl?: boolean;
nestedRoutes?: NestedRouteConfig[];
supportsDynamicSegment?: boolean;
}
export interface NestedRouteConfig {
path: string; // Relative to parent (e.g., 'ask', 'integrations')
titleKey: string; // Translation key or plain text
}
export interface NavItemConfig {
titleKey: string;
path: string;
icon?: Component;
show?: (currentOrg: AccountOrg | null) => boolean;
matchPattern?: string;
items?: NavItemConfig[];
useHashUrl?: boolean;
nestedRoutes?: NestedRouteConfig[];
supportsDynamicSegment?: boolean;
}
// Base navigation configuration structure
const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'lms_navigation.home',
path: '',
icon: LayoutDashboardIcon,
matchPattern: '^/lms/?$'
},
{
titleKey: 'lms_navigation.my_learning',
path: '/mylearning',
icon: LibraryBigIcon,
matchPattern: '^/lms/mylearning(/.*)?$'
},
{
titleKey: 'lms_navigation.exercise',
path: '/exercises',
icon: ListTodoIcon,
matchPattern: '^/lms/exercises(/.*)?$',
show: (currentOrg) => currentOrg?.customization?.dashboard?.exercise === true
},
{
titleKey: 'lms_navigation.community',
path: '/community',
icon: UsersIcon,
matchPattern: '^/lms/community(/.*)?$',
show: (currentOrg) => currentOrg?.customization?.dashboard?.community === true,
supportsDynamicSegment: true, // Supports /community/[slug]
nestedRoutes: [
{
path: 'ask',
titleKey: 'Ask Question' // Could be translated
}
]
},
{
titleKey: 'lms_navigation.explore',
path: '/explore',
icon: MapPlusIcon,
matchPattern: '^/lms/explore(/.*)?$'
},
{
titleKey: 'lms_navigation.settings',
path: '/settings',
icon: CogIcon,
useHashUrl: true,
matchPattern: '^/lms/settings(/.*)?$',
items: [
{
titleKey: 'Profile',
path: '/settings'
},
{
titleKey: 'Integrations',
path: '/settings/integrations'
}
]
}
];
/**
* Get LMS navigation items based on organization context
*/
export function getLmsNavigationItems(
currentOrg: AccountOrg | null,
t: (key: string) => string,
pagePathname: string
): NavItem[] {
const items: NavItem[] = [];
for (const config of baseNavConfig) {
// Skip items that should be hidden based on customization
if (config.show && !config.show(currentOrg)) {
continue;
}
const url = config.path === '' ? '/lms' : `/lms${config.path}`;
const fullPath = config.path === '' ? '/lms' : `/lms${config.path}`;
const item: NavItem = {
title: t(config.titleKey),
url: config.useHashUrl ? '#' : url,
path: config.path,
icon: config.icon,
isActive: isActive(pagePathname, fullPath, config.matchPattern),
isExpanded: config.items ? isActive(pagePathname, fullPath, config.matchPattern) : undefined,
useHashUrl: config.useHashUrl,
nestedRoutes: config.nestedRoutes,
supportsDynamicSegment: config.supportsDynamicSegment,
show: config.show ? () => config.show!(currentOrg) : undefined
};
// Handle nested items (like settings sub-items)
if (config.items) {
item.items = config.items.map((subConfig) => ({
title: t(`settings.tabs.${subConfig.titleKey.toLowerCase()}_tab`) || subConfig.titleKey,
url: `/lms${subConfig.path}`,
path: subConfig.path
}));
}
items.push(item);
}
return items;
}
@@ -1,4 +1,4 @@
import type { AccountOrg } from '$lib/features/app/types';
import type { AccountOrg } from '$features/app/types';
import CogIcon from '@lucide/svelte/icons/cog';
import type { Component } from 'svelte';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';