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:
co-authored by
Abdulmuizz Hamzat
parent
e6a3cf9332
commit
9fb6c7cbaa
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user