feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -65,6 +65,15 @@
const allowNewStudent = $derived(get(courseData, 'metadata.allowNewStudent'));
const bannerImage = $derived(get(courseData, 'logo'));
const instructor = $derived(get(courseData, 'metadata.instructor') || {});
const organizationName = $derived(get(courseData, 'org.name') || $currentOrg.name || '');
const instructorName = $derived.by(() => {
const name = get(instructor, 'name');
return typeof name === 'string' && name.trim() ? name : organizationName;
});
const instructorImageUrl = $derived.by(() => {
const imageUrl = get(instructor, 'imgUrl');
return typeof imageUrl === 'string' && imageUrl.trim() ? imageUrl : '/images/avatar.svg';
});
const certificate = $derived(
get(courseData, 'metadata.certificate', {
templateUrl: '/images/certificate-template.svg'
@@ -148,7 +157,7 @@
<BlurFade delay={0.3}>
<p class="mb-6 text-sm text-blue-300">
{get(courseData, 'metadata.instructor.name', '')}
{instructorName}
</p>
</BlurFade>
@@ -528,11 +537,11 @@
<img
alt="Author Avatar"
class="block h-20 w-20 rounded-full object-cover ring-2 ring-gray-200 dark:ring-neutral-700"
src={get(instructor, 'imgUrl', $currentOrg.avatarUrl || '/logo-512.png')}
src={instructorImageUrl}
/>
<div>
<p class="text-base font-medium dark:text-white">
{get(instructor, 'name', $currentOrg.name)}
{instructorName}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{get(instructor, 'role', '')}
@@ -31,7 +31,7 @@
<span class="grow"></span>
{#if !hideSearch}
<Search />
<Search scope="lms" />
<Popover.Root>
<Popover.Trigger>
@@ -44,7 +44,7 @@ export interface NavItemConfig {
}
// Base navigation configuration structure
const baseNavConfig: NavItemConfig[] = [
export const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'lms_navigation.home',
path: '',
@@ -5,6 +5,7 @@ import {
CourseIcon,
DashboardIcon,
GoalIcon,
LandingPageIcon,
PeopleIcon,
SettingsIcon,
SetupIcon,
@@ -13,8 +14,8 @@ import {
} from '@cio/ui/custom/moving-icons';
import type { AccountOrg } from '$features/app/types';
import type { Component } from 'svelte';
import BotIcon from '@lucide/svelte/icons/bot';
import type { Component } from 'svelte';
import { isActive } from '$lib/utils/functions/app';
export interface NavItem {
@@ -59,7 +60,7 @@ export interface NestedRouteConfig {
}
// Base navigation configuration structure
const baseNavConfig: NavItemConfig[] = [
export const baseNavConfig: NavItemConfig[] = [
{
group: 'home',
titleKey: 'org_navigation.dashboard',
@@ -104,6 +105,13 @@ const baseNavConfig: NavItemConfig[] = [
requiresAdmin: true,
matchPattern: '^/org/[^/]+/tags(/.*)?$'
},
{
group: 'content',
titleKey: 'org_navigation.widgets',
path: '/widgets',
icon: LandingPageIcon,
matchPattern: '^(/org/[^/]+/widgets(/.*)?|/widgets/[^/]+(/.*)?)$'
},
{
group: 'people',
titleKey: 'org_navigation.community',
@@ -4,6 +4,8 @@
import SearchIcon from '@lucide/svelte/icons/search';
import type { ComponentProps } from 'svelte';
import { t } from '$lib/utils/functions/translations';
import { searchStore } from '$features/search/store/search-store.svelte';
import type { SearchScope } from '$features/search/utils/types';
interface Props {
placeholder?: string;
@@ -12,6 +14,7 @@
onFocus?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onKeydown?: (event: KeyboardEvent) => void;
scope?: SearchScope;
}
let {
@@ -21,17 +24,46 @@
onFocus,
onBlur,
onKeydown,
scope = 'org',
...restProps
}: Props & Omit<ComponentProps<typeof InputGroup.Root>, 'class' | 'children'> = $props();
function openCommandPalette() {
searchStore.open(scope);
}
function handleFocus(event: FocusEvent) {
onFocus?.(event);
openCommandPalette();
}
function handlePointerdown(event: PointerEvent) {
event.preventDefault();
openCommandPalette();
}
function handleKeydown(event: KeyboardEvent) {
onKeydown?.(event);
if (event.key === 'Tab') {
return;
}
event.preventDefault();
openCommandPalette();
}
</script>
<InputGroup.Root class={`hidden! max-w-56 md:flex! ${className}`} {...restProps}>
<InputGroup.Root class={`hidden! max-w-56 md:flex! ${className}`} onpointerdown={handlePointerdown} {...restProps}>
<InputGroup.Input
placeholder={placeholder || $t('app.search.placeholder')}
bind:value
on:focus={onFocus}
readonly
role="button"
aria-haspopup="dialog"
on:focus={handleFocus}
on:blur={onBlur}
on:keydown={onKeydown}
on:keydown={handleKeydown}
/>
<InputGroup.Addon>
<SearchIcon />
@@ -15,8 +15,17 @@
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { ROLE } from '@cio/utils/constants';
const sidebar = useSidebar();
function getRoleLabel(roleId: number): string {
if (roleId === ROLE.ADMIN) return $t('course.navItem.people.roles.admin');
if (roleId === ROLE.TUTOR) return $t('course.navItem.people.roles.tutor');
if (roleId === ROLE.STUDENT) return $t('course.navItem.people.roles.student');
return '';
}
</script>
<!--
@@ -32,10 +41,22 @@
</div>
{/snippet}
{#snippet triggeravatar()}
<UserAvatar src={$profile.avatarUrl} alt={$profile.fullname} />
<div class="grid flex-1 text-left text-sm leading-tight font-normal">
<span class="truncate">{$profile.fullname}</span>
<span class="truncate text-xs">{getRoleLabel($currentOrg.roleId)}</span>
</div>
{/snippet}
{#snippet usertrigger()}
<DropdownMenu.Label class="p-0">
<a href={resolve(`${$basePath}/settings`, {})} class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
{@render avatarblock()}
<UserAvatar src={$profile.avatarUrl} alt={$profile.fullname} />
<div class="grid flex-1 text-left text-sm leading-tight font-normal">
<span class="truncate">{$profile.fullname}</span>
<span class="truncate text-xs">{$profile.email}</span>
</div>
</a>
</DropdownMenu.Label>
{/snippet}
@@ -62,7 +83,7 @@
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
{@render avatarblock()}
{@render triggeravatar()}
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
@@ -3,8 +3,9 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import { currentOrg } from '$lib/utils/store/org';
import { Badge } from '@cio/ui/base/badge';
import { PLAN_NAMES, PLAN } from '@cio/utils/plans';
const planName = $derived($currentOrg.plans?.[0]?.planName || 'FREE');
const plan = $derived($currentOrg.plans?.[0]?.planName || PLAN.BASIC);
</script>
<Sidebar.Menu>
@@ -26,7 +27,7 @@
<span class="truncate font-normal">ClassroomIO</span>
<Badge variant="outline" class="capitalize">
{planName.toLowerCase()}
{PLAN_NAMES[plan] || plan}
</Badge>
</a>
{/snippet}
@@ -24,49 +24,82 @@
<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 })}
<HoverableItem>
{#snippet children(isHovered)}
{#if item.disabled}
<span
{...props}
aria-disabled="true"
class="flex cursor-not-allowed items-center gap-2 opacity-50"
>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
</span>
{:else}
<a href={item.url} {...props}>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
{#if item.items}
{#if item.items}
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<HoverableItem>
{#snippet children(isHovered)}
{#if item.disabled}
<span
{...props}
aria-disabled="true"
class="flex cursor-not-allowed items-center gap-2 opacity-50"
>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
</span>
{:else}
<a href={item.url} {...props}>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
<ChevronRightIcon
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/if}
</a>
{/if}
{/snippet}
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
</a>
{/if}
{/snippet}
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{:else}
<Sidebar.MenuButton tooltipContent={item.title} isActive={item.isActive}>
{#snippet child({ props })}
<HoverableItem>
{#snippet children(isHovered)}
{#if item.disabled}
<span
{...props}
aria-disabled="true"
class="flex cursor-not-allowed items-center gap-2 opacity-50"
>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
</span>
{:else}
<a href={item.url} {...props}>
{#if item.icon}
{@const Icon = item.icon}
<Icon {isHovered} size={16} class="custom" />
<span>{item.title}</span>
{:else}
<span>{item.title}</span>
{/if}
</a>
{/if}
{/snippet}
</HoverableItem>
{/snippet}
</Sidebar.MenuButton>
{/if}
{#if item.items}
<Collapsible.Content>
<Sidebar.MenuSub>
@@ -41,7 +41,7 @@
<!-- Self-hosted: show org name only, no switching -->
{#if variant === 'breadcrumb'}
<Breadcrumb.Link href={$currentOrgPath} class="flex items-center gap-2">
{#if $currentOrg.name}
{#if $currentOrg.name}sfsf
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
@@ -92,33 +92,35 @@
</Button>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.sort')}
</p>
<div class="flex flex-wrap gap-2">
{#each sortOptions as option (option.value)}
<Button
type="button"
size="sm"
variant={sortKey === option.value ? 'secondary' : 'outline'}
onclick={() => setSortKey(option.value)}
>
{option.label}
</Button>
{/each}
{#if sortOptions.length > 0}
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.sort')}
</p>
<div class="flex flex-wrap gap-2">
{#each sortOptions as option (option.value)}
<Button
type="button"
size="sm"
variant={sortKey === option.value ? 'secondary' : 'outline'}
onclick={() => setSortKey(option.value)}
>
{option.label}
</Button>
{/each}
</div>
</div>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root type="single" variant="outline" size="sm" value={selectedOrder} onValueChange={setOrder}>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root type="single" variant="outline" size="sm" value={selectedOrder} onValueChange={setOrder}>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
{/if}
{@render additionalContent?.()}
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import { Button, type ButtonVariant } from '@cio/ui/base/button';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { currentOrgDomain } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user';
@@ -20,7 +21,22 @@
variant = 'default'
}: Props = $props();
let href = $derived(isLMS && $user.isLoggedIn ? `${$currentOrgDomain}/home` : $currentOrgDomain);
let href = $derived.by(() => {
const origin = $currentOrgDomain || (browser ? window.location.origin : '');
if (!origin) {
return '';
}
const pathname = isLMS && $user.isLoggedIn ? '/home' : '/';
const url = new URL(pathname, origin);
if (browser && window.location.host.includes('localhost') && $currentOrg.siteName) {
url.searchParams.set('org', $currentOrg.siteName);
}
return url.toString();
});
</script>
<Button {href} target="_blank" {variant} class={`ml-2 ${className}`.trim()}>