feat: widgets and ai chat
This commit is contained in:
@@ -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()}>
|
||||
|
||||
Reference in New Issue
Block a user